diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh
index ce4a322..a475e6a 100755
--- a/.devcontainer/setup.sh
+++ b/.devcontainer/setup.sh
@@ -11,6 +11,4 @@ go mod download
# See https://docs.github.com/en/codespaces/managing-your-codespaces/managing-gpg-verification-for-github-codespaces
git config --global commit.gpgsign false
-make build
-
-make test || echo "tests failed, there may be an issue with this dev container. contribute with care"
\ No newline at end of file
+go build ./...
\ No newline at end of file
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7cd28e8..d74407f 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -5,9 +5,11 @@ on:
- main
paths-ignore:
- 'README.md'
+ - 'docs/*.md'
pull_request:
paths-ignore:
- 'README.md'
+ - 'docs/*.md'
permissions:
contents: read
@@ -15,7 +17,7 @@ permissions:
jobs:
lint:
- name: golangci-lint
+ name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -23,65 +25,43 @@ jobs:
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
- - name: golangci-lint
- uses: golangci/golangci-lint-action@v9
- with:
- version: v2.11.4
+ - name: go vet
+ run: go vet ./...
+ - name: gofmt
+ run: test -z "$(gofmt -l .)"
+
test:
strategy:
matrix:
- os: [windows-latest, ubuntu-latest, ubuntu-24.04-arm]
- name: test
+ os: [ubuntu-latest, windows-latest]
+ name: build
runs-on: ${{ matrix.os }}
steps:
- - name: Checkout code
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
- name: Install Go
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
- name: go build
run: go build ./...
- - name: go test
- run: go test ./... -race
+ - name: go test (compile)
+ run: go test ./...
container:
name: container-build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
+ - uses: actions/checkout@v7
- name: Install Go
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
-
- name: Install ko
run: go install github.com/google/ko@v0.18.1
-
- - name: Set build metadata
- id: meta
- shell: bash
- run: |
- short_sha="${GITHUB_SHA::7}"
- version="${GITHUB_REF_NAME}-${short_sha}"
- echo "version=${version}" >> "$GITHUB_OUTPUT"
- echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
-
- name: Build container image (ko)
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}/gonetsim
- VERSION: ${{ steps.meta.outputs.version }}
- REVISION: ${{ github.sha }}
- BUILD_DATE: ${{ steps.meta.outputs.build_date }}
shell: bash
run: |
- ko build \
- --bare \
- --platform=linux/amd64,linux/arm64 \
- --push=false \
- --image-user=0:0 \
- --tags=main,latest,sha-${GITHUB_SHA} \
- .
+ ko build --bare --platform=linux/amd64,linux/arm64 --push=false .
\ No newline at end of file
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index c861684..2464efc 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -7,10 +7,6 @@ on:
permissions:
contents: write
- packages: write
- id-token: write
- attestations: write
-
jobs:
release:
@@ -25,24 +21,10 @@ jobs:
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
- - name: Install ko
- run: go install github.com/google/ko@v0.18.1
- - name: Log in to GHCR
- uses: docker/login-action@v4
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - name: Download Syft
- uses: anchore/sbom-action/download-syft@v0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --clean
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # After GoReleaser runs, attest all the files in ./dist/checksums.txt:
- - uses: actions/attest@v4
- with:
- subject-checksums: ./dist/checksums.txt
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index f0585d2..f9dc917 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,3 @@
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
# Binaries for programs and plugins
*.exe
*.exe~
@@ -39,5 +36,8 @@ dist/
gonetsim
bin/
-# listener capture output
-artifacts/
\ No newline at end of file
+# runtime output
+tls/
+*.pcapng
+*.keylog
+*.log
\ No newline at end of file
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..a075fd9
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,13 @@
+version: "2"
+
+linters:
+ default: none
+ enable:
+ - govet
+ - ineffassign
+ - staticcheck
+ - misspell
+
+formatters:
+ enable:
+ - gofmt
\ No newline at end of file
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index f3f7d22..48f70b2 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,30 +1,19 @@
-# This is an example .goreleaser.yaml file with some sensible defaults.
-# Make sure to check the documentation at https://goreleaser.com
-
-# The lines below are called `modelines`. See `:help modeline`
-# Feel free to remove those if you don't want/need to use them.
-# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
-# vim: set ts=2 sw=2 tw=0 fo=cnqoj
-
project_name: GoNetSim
version: 2
before:
hooks:
- # You may remove this if you don't use go modules.
- go mod tidy
- # you may remove this if you don't need go generate
- - go generate ./...
builds:
- id: gonetsim
- main: .
+ main: ./cmd/gonetsim
env:
- CGO_ENABLED=0
goos:
- linux
- windows
-# - darwin
+ - darwin
ldflags:
- -s -w
- -X github.com/lachlanharrisdev/gonetsim/cmd.Version={{.Version}}
@@ -33,7 +22,6 @@ builds:
archives:
- formats: tar.gz
- # this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
@@ -41,7 +29,6 @@ archives:
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
- # use zip for windows archives
format_overrides:
- goos: windows
formats: zip
@@ -50,52 +37,46 @@ checksum:
name_template: "checksums.txt"
changelog:
- use: github
- abbrev: 7
- groups:
- - title: "Features"
- regexp: '^feat(\(.+\))?:'
- order: 0
- - title: "Bug Fixes"
- regexp: '^fix(\(.+\))?:'
- order: 1
- - title: "Uncategorized"
- order: 999
- filters:
- exclude:
- - '^docs(\(.+\))?:'
- - '^test(\(.+\))?:'
- - '^refactor(\(.+\))?:'
- - '^chore(\(.+\))?:'
- - '^refc(\(.+\))?:'
+ use: github
+ abbrev: 7
+ groups:
+ - title: "Features"
+ regexp: '^feat(\(.+\))?:'
+ order: 0
+ - title: "Bug Fixes"
+ regexp: '^fix(\(.+\))?:'
+ order: 1
+ - title: "Uncategorized"
+ order: 999
+ filters:
+ exclude:
+ - '^docs(\(.+\))?:'
+ - '^test(\(.+\))?:'
+ - '^refactor(\(.+\))?:'
+ - '^chore(\(.+\))?:'
release:
- github:
- owner: lachlanharrisdev
- name: gonetsim
- draft: false
- replace_existing_draft: false
- use_existing_draft: false
- replace_existing_artifacts: false
-
- name_template: "{{ .Tag }}"
- header: |
- # GoNetSim {{ .Version }}
-
- **Release Date:** {{ .Date }}
- **Latest Included Commit:** {{ .ShortCommit }}
- **Download URL:** {{ .ReleaseURL }}
+ github:
+ owner: lachlanharrisdev
+ name: gonetsim
+ draft: false
+ replace_existing_draft: false
+ use_existing_draft: false
+ replace_existing_artifacts: false
- ---
- footer: |
- ---
+ name_template: "{{ .Tag }}"
+ header: |
+ # GoNetSim {{ .Version }}
- ## Installation
+ **Release Date:** {{ .Date }}
+ **Latest Included Commit:** {{ .ShortCommit }}
+ **Download URL:** {{ .ReleaseURL }}
- Please refer to the [Installation Guide](https://gonetsim.lachlanharris.au/guides/installation)
+ ---
+ footer: |
+ ---
- ### Resources
+ ## Resources
- - [Documentation](https://gonetsim.lachlanharris.au/)
- - [Report Issues](https://github.com/{{ .Env.GITHUB_REPOSITORY }}/issues)
- - [Discussions](https://github.com/{{ .Env.GITHUB_REPOSITORY }}/discussions)
\ No newline at end of file
+ - [Documentation](https://gonetsim.lachlanharris.au/)
+ - [Report Issues](https://github.com/{{ .Env.GITHUB_REPOSITORY }}/issues)
\ No newline at end of file
diff --git a/.ko.yaml b/.ko.yaml
index e8f03d2..376da7a 100644
--- a/.ko.yaml
+++ b/.ko.yaml
@@ -7,7 +7,7 @@ defaultPlatforms:
builds:
- id: gonetsim
dir: .
- main: .
+ main: ./cmd/gonetsim
env:
- CGO_ENABLED=0
flags:
@@ -17,4 +17,4 @@ builds:
- -w
- -X github.com/lachlanharrisdev/gonetsim/cmd.Version={{.Env.VERSION}}
- -X github.com/lachlanharrisdev/gonetsim/cmd.Revision={{.Env.REVISION}}
- - -X github.com/lachlanharrisdev/gonetsim/cmd.Date={{.Env.BUILD_DATE}}
+ - -X github.com/lachlanharrisdev/gonetsim/cmd.Date={{.Env.BUILD_DATE}}
\ No newline at end of file
diff --git a/CITATION.cff b/CITATION.cff
deleted file mode 100644
index 9e55b77..0000000
--- a/CITATION.cff
+++ /dev/null
@@ -1,17 +0,0 @@
-cff-version: 1.2.0
-title: GoNetSim
-type: software
-authors:
- - given-names: Lachlan
- family-names: Harris
- email: contact@lachlanharris.dev
- orcid: 'https://orcid.org/0009-0003-2835-1179'
-identifiers:
- - type: url
- value: >-
- https://github.com/lachlanharrisdev/gonetsim/releases/tag/vX.X.X
- description: >-
- The URL of version vX.X.X of the software. Please
- update this to the used release
-repository-code: 'https://github.com/lachlanharrisdev/gonetsim'
-license: Apache-2.0
diff --git a/README.md b/README.md
index 5121474..dc6000c 100644
--- a/README.md
+++ b/README.md
@@ -1,162 +1,84 @@
-
-
GoNetSim
- Go Network Simulator. A programmable network simulator for malware analysis that lets you simulate any network protocol with small, sandboxed, shareable Lua handlers.
- Explore the docs ยป
-
-
-
-
- [](https://github.com/lachlanharrisdev/gonetsim/stargazers)
- [](https://github.com/lachlanharrisdev/gonetsim?tab=Apache-2.0-1-ov-file)
- [](https://github.com/lachlanharrisdev/gonetsim/)
- [](https://github.com/lachlanharrisdev/gonetsim/actions)
- [](https://github.com/lachlanharrisdev/gonetsim/releases/latest)
-
+ A lightweight network simulator for malware analysis. Point a sandboxed Lua
+ handler at a TCP or UDP port. Every connection is logged to the terminal and
+ written to a pcapng file that opens in Wireshark.
-
-
-
-## Usage
-
-### Installation
-
-Installation instructions can be found [here](https://gonetsim.lachlanharris.au/guides/installation).
-
-### Quick Start
-
-Running `gonetsim` starts all services enabled in the configuration file:
-```sh
-gonetsim
-```
+ [](https://github.com/lachlanharrisdev/gonetsim?tab=Apache-2.0-1-ov-file)
+ [](https://github.com/lachlanharrisdev/gonetsim/actions)
-Individual services and listeners can be selected as targets, as preset names, listener names from config, or inline `handler@addr` listeners:
+
-```sh
-gonetsim run http # just the HTTP service
-gonetsim run http dns # multiple presets
-gonetsim run irc # a named [[listeners]] entry from config
-gonetsim run echo@:7777 # inline echo listener, no config needed
-gonetsim run sink@:9999/udp # inline UDP sink
-gonetsim run c2.lua@:8080 # inline Lua handler from a local script
-```
+## What it is
-Targets named explicitly run regardless of their `enabled` setting in config. Common settings are also available as flags which override the config file:
+GoNetSim simulates the network protocols a malware sample expects to see when it
+phones home. There is no C2 server to install and no protocol libraries to pull
+in. You write a small Lua file that implements the server side, or use one of the
+built-in handlers.
```sh
-gonetsim run http --listen 127.0.0.1:8080
-gonetsim run c2.lua@:8080 --tls --no-capture
-gonetsim run http -s http.mode=real -s http.root_dir=/srv/www
+gonetsim echo@:7777 # built-in echo
+gonetsim handlers/irc.lua@:6667 # Lua handler over TCP
+gonetsim sink@:9999/udp # built-in sink over UDP
```
-A more detailed usage guide can be found [here](https://gonetsim.lachlanharris.au/guides/usage).
-
-
-
-## Configuration
-
-GoNetSim uses a TOML configuration file for most configuration, rather than forcing the memorisation of many flags.
-
-On first run, if no config file is found, GoNetSim generates a default commented config file in `$XDG_CONFIG_HOME/gonetsim/config.toml` and uses it.
-
-Default search locations:
-
-- `./gonetsim.toml`
-- `$XDG_CONFIG_HOME/gonetsim/config.toml` (usually `~/.config/gonetsim/config.toml`)
-- `/etc/gonetsim/gonetsim.toml`
+Everything runs in a sandbox. Scripts cannot read files, run code they did not
+define, or touch the host. Handlers keep state through `conn`, `handler` and
+`global` key/value stores, and they annotate packets for Wireshark with
+`capture:comment()`.
-To use a specific config file:
+## Install
-```yaml
-gonetsim --config /path/to/gonetsim.toml
-```
-
-For more information on configuration, please see the [configuration reference](https://gonetsim.lachlanharris.au/references/configuration)
+Grab the archive for your platform from [Releases](https://github.com/lachlanharrisdev/gonetsim/releases),
+which covers linux, windows and darwin. A container image is published at
+`ghcr.io/lachlanharrisdev/gonetsim` for linux/amd64 and arm64, and the repository
+includes a `docker-compose.yml`.
-
+That is the whole install. The binary embeds its documentation, so it needs no
+network access, including in fully air-gapped labs.
-## Custom Listeners
+## Usage
-Beyond the built-in services, GoNetSim can simulate arbitrary TCP/UDP protocols through custom listeners. Listeners can either use basic builtins or fully custom Lua scripts:
+Every positional argument is a listener in the form `handler@addr[/tcp|/udp]`.
-```toml
-[[listeners]]
-name = "irc"
-type = "tcp"
-listen = ":6667"
-handler = "lua:handlers/irc.lua"
-capture = true
+```sh
+gonetsim handlers/smtp.lua@:2525 --pcap case.pcapng
+gonetsim echo@:8443 --tls --tls-keylog keys.log
+gonetsim handlers/irc.lua@:6667 sink@:9999/udp --log-level debug
```
-Run it with `gonetsim run irc`, or skip using a pre-defined config entirely with `gonetsim run lua:handlers/irc.lua@:6667`.
-
-The [`examples/`](examples/) directory has a full sample config plus example IRC and FTP handlers.
-
-
+The remaining flags are `--listen`, `--timeout`, `--tls`, `--tls-cert`,
+`--tls-key`, `--tls-keylog`, `--pcap`, `--no-pcap`, `--log-level` and
+`--log-format`. A capture is written to `./.pcapng` in the current directory
+unless you pass `--no-pcap`. TLS listeners are captured as ciphertext, so a
+decryptable capture needs `--tls-keylog`. Run `gonetsim docs flags` for the full
+list.
-## Captures
+## Documentation
-Every run saves everything it handles to a single pcapng file, typically `~/.local/share/gonetsim/runs/.pcapng`. GoNetSim prints the path on startup and a packet count on shutdown. Lua handlers can annotate interesting packets with `capture:comment("...")`, which shows up as a packet comment in Wireshark.
+Documentation ships inside the binary and works offline.
```sh
-gonetsim run http --output ./case.pcapng # choose the capture location
-gonetsim pcap ./case.pcapng # summarize a capture
-gonetsim check # also verifies captures can be written
+gonetsim docs # list topics
+gonetsim docs capture # the pcapng file and decrypting TLS
+gonetsim docs lua-api # the sandboxed handler API
+gonetsim docs design # how it is put together
```
-Two things to know when reading captures: handshakes are synthesized (sequence numbers start at 0, Ethernet MACs are fake, timestamps mark when GoNetSim wrote the frame), and TLS services capture ciphertext, not plaintext.
-
-
-
-## Docker
-
-A lightweight distroless container setup lives in `docker/` and is built/published with `ko`. This is the recommended installation method if you require long periods of uptime, or if your system is incompatible with the provided binaries.
-
-For a full reference guide please see the [Docker guide](https://gonetsim.lachlanharris.au/guides/docker)
-
-
-
-## Contributing
-
-GoNetSim follows most standard conventions for contributing, and accepts any contributions from documentation improvements, bug triage / fixes, small features or any updates for [issues in the backlog](https://github.com/lachlanharrisdev/gonetsim/issues?q=is%3Aissue). For more information on contributing please see [CONTRIBUTING.md](https://github.com/lachlanharrisdev/gonetsim/blob/main/.github/CONTRIBUTING.md) and [AI_USAGE.md](https://github.com/lachlanharrisdev/gonetsim/blob/main/.github/AI_USAGE.md)
-
-### Codespaces
-
-GoNetSim has full support for Github Codespaces. These are recommended for small changes or devices with no access to a development environment. You can use the buttons below to open the repository in a web-based editor and get started.
-
-[](https://codespaces.new/lachlanharrisdev/gonetsim?quickstart=1)
-
-### Dev Containers
-
-We also have full support for Dev Containers. These provide a reproducible development environment that automatically isolates the project and installs the officially supported toolchain.
-
-Clicking the below button will open up VS Code on your local machine, clone this repository and open it automatically inside a development container.
-
-[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/lachlanharrisdev/gonetsim)
-
-### Local Development
-
-For local development, please refer to [CONTRIBUTING.md](https://github.com/lachlanharrisdev/gonetsim/blob/main/.github/CONTRIBUTING.md). Again, we follow most conventions so local development involves the standard flow of `fork-PR-merge`.
+The same `docs/*.md` files feed the live website, so one edit lands in both
+places. Example handlers live in `handlers/`.
-
+## Development
----
+GoNetSim is written in Go and needs Go 1.26 or later to build. `go build ./...`
+and `go vet ./...` must pass. There are no tests by policy for a project this
+size. CI builds, vets, lints for real bugs only, and builds the container image.
+Releases are cut from `v*` tags by GoReleaser.
-
+## License
-> This project is licensed under the Apache 2.0 License. Please see [LICENSE](https://github.com/lachlanharrisdev/gonetsim?tab=Apache-2.0-1-ov-file) for more info.
->
-> Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
+Apache 2.0. See [LICENSE](LICENSE).
diff --git a/cmd/check.go b/cmd/check.go
deleted file mode 100644
index 0a140d9..0000000
--- a/cmd/check.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package cmd
-
-import (
- "errors"
- "fmt"
- "net"
- "os"
- "path/filepath"
- "strings"
- "syscall"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/handler"
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
- "github.com/spf13/cobra"
-)
-
-func checkRunDir() error {
- dir, err := capture.DefaultRunsDir()
- if err != nil {
- return fmt.Errorf("runs dir: %w", err)
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("runs dir %q: %w", dir, err)
- }
- f, err := os.CreateTemp(dir, ".writetest-*")
- if err != nil {
- return fmt.Errorf("runs dir %q is not writable: %w", dir, err)
- }
- _ = f.Close()
- _ = os.Remove(f.Name())
- return nil
-}
-
-var checkCmd = &cobra.Command{
- Use: "check",
- Short: "Validate configuration, runs directory, and check enabled services can bind their ports",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- cfgRes, err := appconfig.LoadOrCreate(rootConfigPath)
- if err != nil {
- return err
- }
- configDir := filepath.Dir(cfgRes.Path)
- cfg := cfgRes.Config
-
- out := cmd.OutOrStdout()
- write := func(format string, args ...any) error {
- _, err := fmt.Fprintf(out, format, args...)
- return err
- }
-
- if err := cfg.Validate(); err != nil {
- _ = write("config: FAIL - %v\n", err)
- return err
- }
- if cfgRes.Created {
- if err := write("config: created %s\n", cfgRes.Path); err != nil {
- return err
- }
- } else {
- if err := write("config: OK (%s)\n", cfgRes.Path); err != nil {
- return err
- }
- }
-
- checks := []struct {
- name string
- enabled bool
- run func() (bool, error)
- binds []bindTarget
- }{
- {
- name: "dns",
- enabled: cfg.DNS.Enabled,
- run: func() (bool, error) {
- conf, err := dnsConfig(cfg.DNS)
- return conf.Capture, err
- },
- binds: dnsBindTargets(cfg.DNS.Listen, cfg.DNS.Network),
- },
- {
- name: "http",
- enabled: cfg.HTTP.Enabled,
- run: func() (bool, error) {
- conf, err := httpConfig(cfg.HTTP)
- return conf.Capture, err
- },
- binds: []bindTarget{{net: "tcp", addr: cfg.HTTP.Listen}},
- },
- {
- name: "https",
- enabled: cfg.HTTPS.Enabled,
- run: func() (bool, error) {
- conf, err := httpsConfig(cfg.HTTPS, configDir)
- return conf.Capture, err
- },
- binds: []bindTarget{{net: "tcp", addr: cfg.HTTPS.Listen}},
- },
- }
-
- var failures []string
- fail := func(name string, err error) error {
- failures = append(failures, err.Error())
- return write("%-8s FAIL %v\n", name, err)
- }
- captureWanted := false
- for _, c := range checks {
- if !c.enabled {
- if err := write("%-8s disabled\n", c.name); err != nil {
- return err
- }
- continue
- }
- capturing, err := c.run()
- if err != nil {
- if werr := fail(c.name, err); werr != nil {
- return werr
- }
- continue
- }
- captureWanted = captureWanted || capturing
- if err := preflightBinds(c.binds); err != nil {
- if werr := fail(c.name, err); werr != nil {
- return werr
- }
- continue
- }
- if err := write("%-8s OK\n", c.name); err != nil {
- return err
- }
- }
-
- for _, l := range cfg.Listeners {
- if !l.IsEnabled() {
- if err := write("%-8s disabled\n", l.Name); err != nil {
- return err
- }
- continue
- }
-
- conf, err := listenerConfig(l, configDir)
- if err != nil {
- if werr := fail(l.Name, err); werr != nil {
- return werr
- }
- continue
- }
-
- // compile the lua script to catch errors
- if _, err := handler.New(conf.HandlerSpec, conf.BaseDir, nil); err != nil {
- if werr := fail(l.Name, err); werr != nil {
- return werr
- }
- continue
- }
-
- if err := preflightBinds([]bindTarget{{net: conf.Network, addr: conf.Addr}}); err != nil {
- if werr := fail(l.Name, err); werr != nil {
- return werr
- }
- continue
- }
- captureWanted = captureWanted || conf.Capture
-
- if err := write("%-8s OK %s %s %s\n", l.Name, conf.Network, conf.Addr, conf.HandlerSpec); err != nil {
- return err
- }
- }
-
- if captureWanted {
- if err := checkRunDir(); err != nil {
- if werr := fail("capture", err); werr != nil {
- return werr
- }
- } else if err := write("%-8s OK %s\n", "capture", "runs directory writable"); err != nil {
- return err
- }
- }
-
- if len(failures) > 0 {
- return fmt.Errorf("check failed:\n %s", strings.Join(failures, "\n "))
- }
- return nil
- },
-}
-
-type bindTarget struct {
- net string
- addr string
-}
-
-func dnsBindTargets(listen, network string) []bindTarget {
- switch strings.ToLower(strings.TrimSpace(network)) {
- case "both":
- return []bindTarget{{net: "udp", addr: listen}, {net: "tcp", addr: listen}}
- case "tcp":
- return []bindTarget{{net: "tcp", addr: listen}}
- default:
- return []bindTarget{{net: "udp", addr: listen}}
- }
-}
-
-func preflightBinds(targets []bindTarget) error {
- for _, t := range targets {
- if err := tryBind(t.net, t.addr); err != nil {
- return describeBindError(t, err)
- }
- }
- return nil
-}
-
-func tryBind(network, addr string) error {
- switch network {
- case "udp":
- pc, err := net.ListenPacket("udp", addr)
- if err != nil {
- return err
- }
- return pc.Close()
- case "tcp":
- ln, err := net.Listen("tcp", addr)
- if err != nil {
- return err
- }
- return ln.Close()
- default:
- return fmt.Errorf("unsupported network %q", network)
- }
-}
-
-func describeBindError(t bindTarget, err error) error {
- addr := t.addr
- if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) {
- if port, ok := netx.ParsePort(addr); ok && port < 1024 {
- return fmt.Errorf("cannot bind %s: permission denied (ports below 1024 require elevated privileges on this system)", addr)
- }
- }
- if errors.Is(err, syscall.EADDRINUSE) {
- return fmt.Errorf("cannot bind %s: address already in use", addr)
- }
- return fmt.Errorf("cannot bind %s: %w", addr, err)
-}
-
-func init() {
- rootCmd.AddCommand(checkCmd)
-}
diff --git a/cmd/docs.go b/cmd/docs.go
index 3334713..fbd9eb5 100644
--- a/cmd/docs.go
+++ b/cmd/docs.go
@@ -2,21 +2,65 @@ package cmd
import (
"fmt"
+ "os"
+ "strings"
+ "github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
+
+ markdown "github.com/lachlanharrisdev/go-std-markdown"
+
+ "github.com/lachlanharrisdev/gonetsim/docs"
)
+// docsRaw prints the markdown source untouched instead of rendering it to the
+// terminal, for piping into a file or another markdown tool.
+var docsRaw bool
+
var docsCmd = &cobra.Command{
- Use: "docs",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Println("GoNetSim's official documentation can be found at https://gonetsim.lachlanharris.dev")
- fmt.Print("This documentation includes in-depth guides, references for services & detailed specifications\n\n")
- fmt.Println("The official repository is located at https://github.com/lachlanharrisdev/gonetsim")
- fmt.Print("This is the source for issue tracking, discussions, downloads and contribution guidelines\n\n")
+ Use: "docs [topic]",
+ Short: "Show built-in documentation (works offline)",
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ names := docs.Names()
+ if len(args) == 0 {
+ var b strings.Builder
+ b.WriteString("GoNetSim built-in docs. Pick a topic:\n")
+ for _, n := range names {
+ fmt.Fprintf(&b, " docs %s\n", n)
+ }
+ _, err := fmt.Fprint(cmd.OutOrStdout(), b.String())
+ return err
+ }
+ content, err := docs.Read(args[0])
+ if err != nil {
+ return err
+ }
+ if docsRaw {
+ _, err := fmt.Fprint(cmd.OutOrStdout(), content)
+ return err
+ }
+ rendered := markdown.Render(content, docsLineWidth(), 0)
+ _, err = cmd.OutOrStdout().Write(rendered)
+ return err
},
- Short: "Shows link to GoNetSim documentation",
+}
+
+// docsLineWidth picks a rendering width from the terminal. It falls back to 80
+// when stdout is not a terminal and caps at 100 so lines stay comfortable on
+// wide monitors.
+func docsLineWidth() int {
+ w, _, err := term.GetSize(uintptr(os.Stdout.Fd()))
+ if err != nil || w < 40 {
+ return 80
+ }
+ if w > 100 {
+ return 100
+ }
+ return w
}
func init() {
+ docsCmd.Flags().BoolVar(&docsRaw, "raw", false, "print the raw markdown source instead of rendering it")
rootCmd.AddCommand(docsCmd)
}
diff --git a/cmd/license.go b/cmd/license.go
deleted file mode 100644
index d67208f..0000000
--- a/cmd/license.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package cmd
-
-import (
- "fmt"
-
- "github.com/spf13/cobra"
-)
-
-var licenseCmd = &cobra.Command{
- Use: "license",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Println("GoNetSim is licensed under the Apache 2.0 License.")
- fmt.Println("")
- fmt.Println("To view this license, or in the event of license changes, please see the LICENSE file at https://github.com/lachlanharrisdev/gonetsim/blob/main/LICENSE")
- },
- Short: "Show license information",
-}
-
-func init() {
- rootCmd.AddCommand(licenseCmd)
-}
diff --git a/cmd/pcap.go b/cmd/pcap.go
deleted file mode 100644
index b7fce3f..0000000
--- a/cmd/pcap.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/spf13/cobra"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
-)
-
-var pcapCmd = &cobra.Command{
- Use: "pcap ",
- Short: "Inspect pcapng capture files",
- Long: "Reads pcapng capture files and prints a summary. Pass a single file\n" +
- "or a directory (e.g. the runs directory) to summarize every capture beneath it.\n" +
- "Legacy pcap files are not supported",
- Args: cobra.ExactArgs(1),
- RunE: runPcap,
-}
-
-func init() {
- rootCmd.AddCommand(pcapCmd)
-}
-
-func runPcap(cmd *cobra.Command, args []string) error {
- return inspectPcap(cmd.OutOrStdout(), args[0])
-}
-
-func inspectPcap(out io.Writer, target string) error {
- st, err := os.Stat(target)
- if err != nil {
- return fmt.Errorf("pcap %q: %w", target, err)
- }
- if st.IsDir() {
- return inspectPcapDir(out, target)
- }
- info, err := capture.Inspect(target)
- if err != nil {
- return err
- }
- if _, err := fmt.Fprintf(out, "%s\n", summarizePcap(target, info)); err != nil {
- return fmt.Errorf("write output: %w", err)
- }
- return nil
-}
-
-func summarizePcap(path string, info capture.FileInfo) string {
- var sb strings.Builder
- fmt.Fprintf(&sb, "%s: format=pcapng linktype=%s packets=%d", path, info.LinkType, info.Packets)
- if info.Packets > 0 {
- fmt.Fprintf(&sb, " first=%s last=%s duration=%s",
- info.First.Format(time.RFC3339), info.Last.Format(time.RFC3339),
- info.Last.Sub(info.First).Round(time.Millisecond))
- }
- if len(info.Interfaces) > 0 {
- fmt.Fprintf(&sb, " interfaces=%s", strings.Join(info.Interfaces, "|"))
- }
- if info.CreatedBy != "" {
- fmt.Fprintf(&sb, " app=%s", info.CreatedBy)
- }
- return sb.String()
-}
-
-func inspectPcapDir(out io.Writer, dir string) error {
- var files []string
- walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".pcapng") {
- files = append(files, path)
- }
- return nil
- })
- if walkErr != nil {
- return fmt.Errorf("pcap %q: %w", dir, walkErr)
- }
- sort.Strings(files)
- if len(files) == 0 {
- return fmt.Errorf("no pcapng files found in %q", dir)
- }
- var total uint64
- failed := 0
- for _, f := range files {
- info, err := capture.Inspect(f)
- if err != nil {
- if _, werr := fmt.Fprintf(out, "%s: ERROR %v\n", f, err); werr != nil {
- return fmt.Errorf("write output: %w", werr)
- }
- failed++
- continue
- }
- if _, err := fmt.Fprintf(out, "%s\n", summarizePcap(f, info)); err != nil {
- return fmt.Errorf("write output: %w", err)
- }
- total += info.Packets
- }
- if _, err := fmt.Fprintf(out, "total: files=%d packets=%d\n", len(files), total); err != nil {
- return fmt.Errorf("write output: %w", err)
- }
- if failed > 0 {
- return fmt.Errorf("%d of %d files could not be read", failed, len(files))
- }
- return nil
-}
diff --git a/cmd/pcap_test.go b/cmd/pcap_test.go
deleted file mode 100644
index 37ab03d..0000000
--- a/cmd/pcap_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package cmd
-
-import (
- "bytes"
- "net/netip"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
-)
-
-func writePcapFixture(t *testing.T, path string, payloads ...string) {
- t.Helper()
- local := netip.MustParseAddrPort("127.0.0.1:8080")
- remote := netip.MustParseAddrPort("10.0.0.5:40000")
- run, err := capture.NewRun(path)
- if err != nil {
- t.Fatalf("NewRun: %v", err)
- }
- defer func() { _ = run.Close() }()
- iface, err := run.NewInterface("test")
- if err != nil {
- t.Fatalf("NewInterface: %v", err)
- }
- ses, err := run.NewSession("tcp", local, remote, iface)
- if err != nil {
- t.Fatalf("NewSession: %v", err)
- }
- for _, p := range payloads {
- if err := ses.Write([]byte(p), true); err != nil {
- t.Fatalf("Write: %v", err)
- }
- }
- if err := ses.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-}
-
-func TestInspectPcapFile(t *testing.T) {
- path := filepath.Join(t.TempDir(), "flow.pcapng")
- writePcapFixture(t, path, "hello", "world")
-
- var out bytes.Buffer
- if err := inspectPcap(&out, path); err != nil {
- t.Fatalf("inspectPcap: %v", err)
- }
- got := out.String()
- for _, want := range []string{"format=pcapng", "packets=", "first=", "last=", "duration="} {
- if !strings.Contains(got, want) {
- t.Errorf("output %q missing %q", got, want)
- }
- }
-}
-
-func TestInspectPcapDir(t *testing.T) {
- dir := t.TempDir()
- writePcapFixture(t, filepath.Join(dir, "b.pcapng"), "one")
- writePcapFixture(t, filepath.Join(dir, "a.pcapng"), "one", "two")
- if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
-
- var out bytes.Buffer
- if err := inspectPcap(&out, dir); err != nil {
- t.Fatalf("inspectPcap: %v", err)
- }
- got := out.String()
- if !strings.Contains(got, "total: files=2") {
- t.Errorf("missing totals line: %q", got)
- }
- if strings.Contains(got, "notes.txt") {
- t.Errorf("non-pcapng file should be skipped: %q", got)
- }
- if a, b := strings.Index(got, "a.pcapng"), strings.Index(got, "b.pcapng"); a < 0 || b < 0 || a > b {
- t.Errorf("files should be listed sorted: %q", got)
- }
-}
-
-func TestInspectPcapDirWithBadFile(t *testing.T) {
- dir := t.TempDir()
- writePcapFixture(t, filepath.Join(dir, "good.pcapng"), "one")
- if err := os.WriteFile(filepath.Join(dir, "bad.pcapng"), []byte("not a capture"), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
-
- var out bytes.Buffer
- err := inspectPcap(&out, dir)
- if err == nil || !strings.Contains(err.Error(), "could not be read") {
- t.Fatalf("expected unreadable-file error, got %v", err)
- }
- if got := out.String(); !strings.Contains(got, "good.pcapng") || !strings.Contains(got, "bad.pcapng: ERROR") {
- t.Errorf("good files should still be listed alongside errors: %q", got)
- }
-}
-
-func TestInspectPcapFailures(t *testing.T) {
- var out bytes.Buffer
- if err := inspectPcap(&out, filepath.Join(t.TempDir(), "empty")); err == nil {
- t.Errorf("expected error for directory without captures")
- }
- if err := inspectPcap(&out, filepath.Join(t.TempDir(), "missing.pcapng")); err == nil {
- t.Errorf("expected error for missing file")
- }
- bad := filepath.Join(t.TempDir(), "bad.pcapng")
- if err := os.WriteFile(bad, []byte("not a capture"), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
- if err := inspectPcap(&out, bad); err == nil {
- t.Errorf("expected error for corrupt file")
- }
-}
diff --git a/cmd/root.go b/cmd/root.go
index 89d3579..aa49670 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,31 +1,81 @@
+// Package cmd wires the gonetsim command line: the default run command plus
+// the tls, version and docs subcommands.
package cmd
import (
- "log/slog"
- "os"
+ "fmt"
+ "strconv"
+ "time"
"github.com/spf13/cobra"
)
-var rootConfigPath string
+const defaultIdleTimeout = 30 * time.Second
+
+type runOptions struct {
+ listen string
+ timeout time.Duration
+ tls bool
+ tlsCert string
+ tlsKey string
+ tlsKeylog string
+ pcap string
+ noPcap bool
+ logLevel string
+ logFormat string
+}
+
+var runOpts runOptions
+
+// timeoutValue accepts either a bare number of seconds or a Go duration, so
+// --timeout 5 and --timeout 5s both mean five seconds.
+type timeoutValue struct{ d *time.Duration }
+
+func newTimeoutValue(d *time.Duration) *timeoutValue { return &timeoutValue{d: d} }
+
+func (v *timeoutValue) Set(s string) error {
+ if d, err := time.ParseDuration(s); err == nil {
+ *v.d = d
+ return nil
+ }
+ n, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return fmt.Errorf("invalid duration %q (use e.g. 5, 5s, 1m30s)", s)
+ }
+ *v.d = time.Duration(n * float64(time.Second))
+ return nil
+}
+
+func (v *timeoutValue) String() string { return v.d.String() }
+
+func (v *timeoutValue) Type() string { return "duration" }
var rootCmd = &cobra.Command{
- Use: "gonetsim [targets...]",
- Short: "Start all enabled services, or only the given targets",
+ Use: "gonetsim [handler@addr ...]",
+ Short: "A lightweight, sandboxed Lua network simulator",
+ Long: "Run sandboxed Lua handlers (or the echo/sink builtins) on\nTCP or UDP listeners, with terminal logs and pcapng capture.\n\n gonetsim irc.lua@:6667\n gonetsim echo@:7777 sink@:9999/udp --pcap ./case.pcapng",
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true,
+ Version: Version,
RunE: runTargets,
}
-func Execute() {
- if err := rootCmd.Execute(); err != nil {
- slog.Error("fatal error", "err", err)
- os.Exit(1)
- }
+// Execute runs the root command, returning any error instead of exiting.
+func Execute() error {
+ return rootCmd.Execute()
}
func init() {
- rootCmd.PersistentFlags().StringVar(&rootConfigPath, "config", "", "path to config TOML file (optional)")
- addRunFlags(rootCmd)
+ flags := rootCmd.Flags()
+ flags.StringVar(&runOpts.logLevel, "log-level", "info", "log level (debug, info, warn, error)")
+ flags.StringVar(&runOpts.logFormat, "log-format", "text", "log format (text, json)")
+ flags.StringVar(&runOpts.listen, "listen", "", "override the listen address (requires exactly one target)")
+ flags.Var(newTimeoutValue(&runOpts.timeout), "timeout", "connection idle timeout (default 30s; 0 disables)")
+ flags.BoolVar(&runOpts.tls, "tls", false, "wrap tcp listeners in TLS (self-signed unless --tls-cert/--tls-key are given)")
+ flags.StringVar(&runOpts.tlsCert, "tls-cert", "", "TLS certificate file, used with --tls")
+ flags.StringVar(&runOpts.tlsKey, "tls-key", "", "TLS key file, used with --tls")
+ flags.StringVar(&runOpts.tlsKeylog, "tls-keylog", "", "write TLS session keys to f so Wireshark can decrypt the capture (SSLKEYLOGFILE format); requires --tls")
+ flags.StringVar(&runOpts.pcap, "pcap", "", "write the run capture to this pcapng file (default ./.pcapng)")
+ flags.BoolVar(&runOpts.noPcap, "no-pcap", false, "don't write a capture file")
}
diff --git a/cmd/run.go b/cmd/run.go
index bb5b735..cd57cf1 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -2,8 +2,11 @@ package cmd
import (
"context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
"fmt"
- "log/slog"
+ "net"
"os"
"os/signal"
"path/filepath"
@@ -13,150 +16,256 @@ import (
"github.com/spf13/cobra"
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/observability"
- "github.com/lachlanharrisdev/gonetsim/internal/service"
- "github.com/lachlanharrisdev/gonetsim/internal/state"
+ "github.com/lachlanharrisdev/gonetsim/internal/app"
+ "github.com/lachlanharrisdev/gonetsim/internal/logging"
+ "github.com/lachlanharrisdev/gonetsim/internal/network"
+ "github.com/lachlanharrisdev/gonetsim/internal/pcap"
+ "github.com/lachlanharrisdev/gonetsim/internal/script"
+ "github.com/lachlanharrisdev/gonetsim/internal/store"
+ "github.com/lachlanharrisdev/gonetsim/internal/tlscert"
)
-type runOptions struct {
- sets []string
- listen string
- timeout time.Duration
- tls bool
- noCapture bool
- output string
-}
-
-var runOpts runOptions
-
-func addRunFlags(cmd *cobra.Command) {
- cmd.Flags().StringArrayVarP(&runOpts.sets, "set", "s", nil,
- "override a config key (repeatable), e.g. -s http.mode=real -s dns.ipv4=10.0.0.1")
- cmd.Flags().StringVar(&runOpts.listen, "listen", "",
- "override the listen address (requires exactly one target)")
- cmd.Flags().DurationVar(&runOpts.timeout, "timeout", 0,
- "idle read timeout for inline listeners (default 30s)")
- cmd.Flags().BoolVar(&runOpts.tls, "tls", false,
- "wrap inline tcp listeners in TLS with an in-memory self-signed certificate")
- cmd.Flags().BoolVar(&runOpts.noCapture, "no-capture", false,
- "don't write a capture file for this run")
- cmd.Flags().StringVar(&runOpts.output, "output", "",
- "write the run capture to this pcapng file instead of the default runs directory")
-}
-
-var runCmd = &cobra.Command{
- Use: "run [targets...]",
- Short: "Run the given targets; presets, listener names, or inline handler@addr",
- Args: cobra.ArbitraryArgs,
- SilenceUsage: true,
- SilenceErrors: true,
- RunE: runTargets,
-}
-
-func init() {
- rootCmd.AddCommand(runCmd)
- addRunFlags(runCmd)
-}
-
+// runTargets is the default command: start one listener per handler@addr.
func runTargets(cmd *cobra.Command, args []string) error {
- specs, err := parseTargets(args)
+ specs, err := parseSpecs(args)
if err != nil {
return err
}
-
- overrides, err := parseSets(runOpts.sets)
- if err != nil {
- return err
- }
-
- var cfgRes appconfig.LoadResult
if len(specs) == 0 {
- cfgRes, err = appconfig.LoadOrCreateWithOverrides(rootConfigPath, overrides)
- } else {
- cfgRes, err = appconfig.LoadOptional(rootConfigPath, overrides)
+ return errors.New("no targets given\n\nusage: gonetsim [handler@addr ...] [flags]\n\n gonetsim echo@:7777\n gonetsim handlers/irc.lua@:6667\n gonetsim sink@:9999/udp --pcap ./case.pcapng\n\nsee: gonetsim docs quickstart")
}
- if err != nil {
- return err
+ if runOpts.listen != "" && len(specs) > 1 {
+ return fmt.Errorf("--listen requires exactly one target, got %d", len(specs))
}
- configDir := filepath.Dir(cfgRes.Path)
- cfg := cfgRes.Config
- if err := cfg.Validate(); err != nil {
- return err
+ if runOpts.pcap != "" && runOpts.noPcap {
+ return errors.New("--pcap and --no-pcap cannot be used together")
}
-
- logger, err := observability.NewLogger(observability.Options{Format: cfg.Logging.LogFormat, Level: cfg.Logging.Level})
- if err != nil {
- return err
+ if !runOpts.tls && (runOpts.tlsCert != "" || runOpts.tlsKey != "") {
+ return errors.New("--tls-cert/--tls-key require --tls")
}
- slog.SetDefault(logger)
- if cfgRes.Created {
- logger.Info("config created", "path", cfgRes.Path)
- }
- if cfgRes.Path != "" {
- logger.Info("config loaded", "path", cfgRes.Path)
- } else {
- logger.Info("no config file found, using defaults")
+ if !runOpts.tls && cmd.Flags().Changed("tls-keylog") {
+ return errors.New("--tls-keylog requires --tls")
}
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
- runCtx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- cwd, err := os.Getwd()
+ logger, err := logging.New(runOpts.logLevel, runOpts.logFormat)
if err != nil {
return err
}
- limit, err := appconfig.ParseSize(cfg.State.TotalLimit)
- if err != nil {
- return err
+ // Build TLS before opening the capture file so a bad cert/keystore doesn't
+ // leave an empty .pcapng behind.
+ var tlsConf *tls.Config
+ keylogPath := ""
+ if runOpts.tls {
+ conf := tlscert.Config{CertFile: runOpts.tlsCert, KeyFile: runOpts.tlsKey}
+ tlsConf, err = conf.TLSConfig()
+ if err != nil {
+ return err
+ }
+ if cmd.Flags().Changed("tls-keylog") {
+ if runOpts.tlsKeylog == "" {
+ return errors.New("--tls-keylog requires a file path")
+ }
+ if dir := filepath.Dir(runOpts.tlsKeylog); dir != "." && dir != "" {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("create output dir %q: %w", dir, err)
+ }
+ }
+ keylogPath = runOpts.tlsKeylog
+ klf, err := os.OpenFile(keylogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return fmt.Errorf("create tls key log %q: %w", keylogPath, err)
+ }
+ tlsConf.KeyLogWriter = klf
+ logger.Info("key log: " + keylogPath)
+ defer func() { _ = klf.Close() }()
+ }
+ if keylogPath == "" && !runOpts.noPcap {
+ logger.Warn("tls listeners are captured as ciphertext on the wire; pass --tls-keylog to save session keys so Wireshark can decrypt the capture")
+ }
}
- global := state.NewStore(state.NewBudget(limit))
- var run *capture.Run
- if !runOpts.noCapture {
- path, err := capture.RunPath(runOpts.output)
+ var run *pcap.Run
+ if !runOpts.noPcap {
+ path, err := pcap.DefaultPath(runOpts.pcap)
if err != nil {
return err
}
- run, err = capture.NewRun(path)
+ manifest, err := json.Marshal(buildManifest(cmd, specs, keylogPath))
+ if err != nil {
+ // Never let a metadata hiccup block the capture.
+ logger.Warn("skip capture manifest: " + err.Error())
+ manifest = nil
+ }
+ run, err = pcap.NewRun(path, string(manifest))
if err != nil {
return err
}
- logger.Info("capture", "path", path)
+ logger.Info("capture: " + path)
+ // Close any orphaned capture if we fail further down; the success path
+ // below nils `run` after closing it inline to print stats.
+ defer func() {
+ if run != nil {
+ _ = run.Close()
+ }
+ }()
}
- resolved, err := resolveTargets(specs, &cfg, configDir, cwd, runOpts, logger, global, run)
+ budget := store.NewBudget(store.DefaultTotalLimit)
+ global := store.NewStore(budget)
+ cwd, err := os.Getwd()
if err != nil {
- if run != nil {
- _ = run.Close()
- }
return err
}
- if len(resolved) == 0 {
- if run != nil {
- _ = run.Close()
+
+ runners := make([]func(context.Context) error, 0, len(specs))
+ displays := make([]string, 0, len(specs))
+
+ for _, sp := range specs {
+ if runOpts.tls && sp.network != "tcp" {
+ return fmt.Errorf("listener %s: --tls requires a tcp listener", sp.name)
+ }
+ h, err := script.New(sp.handlerSpec, cwd, budget)
+ if err != nil {
+ return fmt.Errorf("listener %s handler: %w", sp.name, err)
}
- return fmt.Errorf("at least one service must be enabled")
- }
- displays := make([]string, len(resolved))
- manager := service.NewManager(cfg.General.ShutdownTimeout, logger)
- for i, rt := range resolved {
- displays[i] = rt.display
- manager.Add(rt.svc)
+ addr := sp.addr
+ if runOpts.listen != "" {
+ addr = runOpts.listen
+ }
+ idle := defaultIdleTimeout
+ if cmd.Flags().Changed("timeout") {
+ idle = runOpts.timeout // 0 disables the idle deadline
+ }
+
+ log := logging.WithPrefix(logger, sp.name)
+ displays = append(displays, displayName(sp, addr))
+
+ if sp.network == "udp" {
+ runners = append(runners, func(ctx context.Context) error {
+ return network.ServeUDP(ctx, sp.name, addr, run, idle, log, func(ctx context.Context, data []byte, from net.Addr, ses *pcap.Session) ([]byte, error) {
+ return h.ServeUDP(ctx, data, from, script.Env{Logger: log, Capture: ses, Global: global})
+ })
+ })
+ continue
+ }
+ runners = append(runners, func(ctx context.Context) error {
+ return network.ServeTCP(ctx, sp.name, addr, tlsConf, run, idle, log, func(ctx context.Context, conn net.Conn, ses *pcap.Session) error {
+ return h.ServeTCP(ctx, conn, script.Env{Logger: log, Capture: ses, IdleTimeout: idle, Global: global})
+ })
+ })
}
- logger.Info("running", "targets", strings.Join(displays, " "))
- runErr := manager.RunAll(runCtx)
+ logger.Info("running: " + strings.Join(displays, " "))
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ // Run app.RunAll in a goroutine so a second Ctrl-C during graceful
+ // shutdown can force-exit even when shutdown is taking a long time.
+ doneCh := make(chan struct{})
+ go func() {
+ defer close(doneCh)
+ err = app.RunAll(ctx, runners...)
+ }()
+
+ go func() {
+ // Wait for either a signal or shutdown to complete.
+ select {
+ case <-ctx.Done():
+ logger.Info("shutting down")
+ case <-doneCh:
+ return // already finished, nothing to force-exit from
+ }
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+ select {
+ case <-c:
+ logger.Warn("forced exit")
+ os.Exit(1)
+ case <-doneCh:
+ return // finished before second signal
+ }
+ }()
+
+ <-doneCh
+
if run != nil {
packets, first, last := run.Stats()
path := run.Path()
_ = run.Close()
- logger.Info("capture saved", "path", path, "packets", packets, "duration", last.Sub(first).Round(time.Millisecond))
+ run = nil // tell the cleanup defer to skip
+ msg := fmt.Sprintf("capture saved: %s (%d packets, %s)", path, packets, last.Sub(first).Round(time.Millisecond))
+ if keylogPath != "" {
+ msg += ", keys: " + keylogPath + " (load in Wireshark to decrypt)"
+ }
+ logger.Info(msg)
+ }
+ return err
+}
+
+func displayName(sp spec, addr string) string {
+ display := sp.name + "(" + addr
+ if sp.network == "udp" {
+ display += "/udp"
+ }
+ if runOpts.tls {
+ display += "+tls"
+ }
+ return display + ")"
+}
+
+// runManifest is embedded in the capture's section header block so the single
+// pcapng file is self-describing: which handlers simulated what, and how.
+type runManifest struct {
+ Version string `json:"version"`
+ Revision string `json:"revision"`
+ Date string `json:"date"`
+ Listen string `json:"listen,omitempty"`
+ Timeout string `json:"timeout"`
+ TLS bool `json:"tls"`
+ TLSCert string `json:"tlsCert,omitempty"`
+ TLSKey string `json:"tlsKey,omitempty"`
+ TLSKeylog string `json:"tlsKeylog,omitempty"`
+ LogLevel string `json:"logLevel"`
+ Listeners []manifestListener `json:"listeners"`
+}
+
+type manifestListener struct {
+ Handler string `json:"handler"`
+ Addr string `json:"addr"`
+ Network string `json:"network"`
+}
+
+func buildManifest(cmd *cobra.Command, specs []spec, keylogPath string) runManifest {
+ m := runManifest{
+ Version: Version,
+ Revision: Revision,
+ Date: Date,
+ Listen: runOpts.listen,
+ Timeout: effectiveTimeout(cmd).String(),
+ TLS: runOpts.tls,
+ LogLevel: runOpts.logLevel,
+ }
+ if runOpts.tlsCert != "" {
+ m.TLSCert = filepath.Base(runOpts.tlsCert)
+ }
+ if runOpts.tlsKey != "" {
+ m.TLSKey = filepath.Base(runOpts.tlsKey)
+ }
+ if keylogPath != "" {
+ m.TLSKeylog = filepath.Base(keylogPath)
+ }
+ for _, sp := range specs {
+ m.Listeners = append(m.Listeners, manifestListener{Handler: sp.name, Addr: sp.addr, Network: sp.network})
+ }
+ return m
+}
+
+func effectiveTimeout(cmd *cobra.Command) time.Duration {
+ if cmd.Flags().Changed("timeout") {
+ return runOpts.timeout
}
- return runErr
+ return defaultIdleTimeout
}
diff --git a/cmd/script.go b/cmd/script.go
deleted file mode 100644
index b087d07..0000000
--- a/cmd/script.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package cmd
-
-import (
- "net"
- "os"
- "time"
-
- "github.com/spf13/cobra"
-
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/handler"
- "github.com/lachlanharrisdev/gonetsim/internal/observability"
- "github.com/lachlanharrisdev/gonetsim/internal/state"
-)
-
-var scriptCmd = &cobra.Command{
- Use: "script ",
- Short: "Test a Lua handler interactively over stdin/stdout",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- h, err := handler.NewLua(args[0], nil)
- if err != nil {
- return err
- }
-
- def := appconfig.Default().Logging
- logger, err := observability.NewLogger(observability.Options{Format: def.LogFormat, Level: def.Level})
- if err != nil {
- return err
- }
-
- global := state.NewStore(nil)
- return h.HandleTCP(cmd.Context(), stdioConn{}, handler.Env{Logger: logger, Global: global})
- },
-}
-
-func init() {
- rootCmd.AddCommand(scriptCmd)
-}
-
-type stdioAddr struct{}
-
-func (stdioAddr) Network() string { return "stdio" }
-func (stdioAddr) String() string { return "stdio" }
-
-type stdioConn struct{}
-
-func (stdioConn) Read(p []byte) (int, error) { return os.Stdin.Read(p) }
-func (stdioConn) Write(p []byte) (int, error) { return os.Stdout.Write(p) }
-func (stdioConn) Close() error { return nil }
-func (stdioConn) LocalAddr() net.Addr { return stdioAddr{} }
-func (stdioConn) RemoteAddr() net.Addr { return stdioAddr{} }
-func (stdioConn) SetDeadline(time.Time) error { return nil }
-func (stdioConn) SetReadDeadline(time.Time) error { return nil }
-func (stdioConn) SetWriteDeadline(time.Time) error { return nil }
diff --git a/cmd/servicecfg.go b/cmd/servicecfg.go
deleted file mode 100644
index c47e0c1..0000000
--- a/cmd/servicecfg.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "net/netip"
- "strings"
- "time"
-
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/dnsserver"
- "github.com/lachlanharrisdev/gonetsim/internal/httpserver"
- "github.com/lachlanharrisdev/gonetsim/internal/listener"
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
- "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider"
-)
-
-func parseNetipAddr(s string) (netip.Addr, error) {
- a, err := netip.ParseAddr(s)
- if err != nil {
- return netip.Addr{}, fmt.Errorf("invalid ip %q: %w", s, err)
- }
- return a, nil
-}
-
-func parseOptionalNetipAddr(s string) (netip.Addr, error) {
- if s == "" {
- return netip.Addr{}, nil
- }
- return parseNetipAddr(s)
-}
-
-const defaultReadTimeout = 30 * time.Second
-
-func listenerConfig(l appconfig.ListenerConfig, configDir string) (listener.Config, error) {
- listen, err := netx.ParseAddr(l.Listen)
- if err != nil {
- return listener.Config{}, fmt.Errorf("listener %s.listen: %w", l.Name, err)
- }
-
- readTimeout := l.ReadTimeout
- if readTimeout <= 0 {
- readTimeout = defaultReadTimeout
- }
-
- network := l.Type
- if network == "" {
- network = "tcp"
- }
-
- conf := listener.Config{
- Name: l.Name,
- Network: network,
- Addr: listen,
- HandlerSpec: l.Handler,
- ReadTimeout: readTimeout,
- Capture: l.ShouldCapture(),
- BaseDir: configDir,
- }
-
- if l.TLS || l.TLSCert != "" || l.TLSKey != "" {
- certPath, keyPath := l.TLSCert, l.TLSKey
- if certPath == "" && keyPath == "" {
- certPath, keyPath = tlsprovider.DefaultPaths(configDir)
- }
- conf.TLS = &tlsprovider.Config{CertFile: certPath, KeyFile: keyPath}
- }
-
- if err := conf.Validate(); err != nil {
- return listener.Config{}, fmt.Errorf("listener %s: %w", l.Name, err)
- }
- return conf, nil
-}
-
-func dnsIPv4(s string) (netip.Addr, error) {
- if strings.EqualFold(strings.TrimSpace(s), dnsserver.AutoIPv4) {
- return dnsserver.AutoSinkholeIPv4(), nil
- }
- return parseNetipAddr(s)
-}
-
-func dnsConfig(cfg appconfig.DNSConfig) (dnsserver.Config, error) {
- listen, err := netx.ParseAddr(cfg.Listen)
- if err != nil {
- return dnsserver.Config{}, fmt.Errorf("dns.listen: %w", err)
- }
- ipv4, err := dnsIPv4(cfg.IPv4)
- if err != nil {
- return dnsserver.Config{}, fmt.Errorf("dns.ipv4: %w", err)
- }
- ipv6, err := parseOptionalNetipAddr(cfg.IPv6)
- if err != nil {
- return dnsserver.Config{}, fmt.Errorf("dns.ipv6: %w", err)
- }
- conf := dnsserver.Config{
- Addr: listen,
- Net: cfg.Network,
- SinkholeIPv4: ipv4,
- SinkholeIPv6: ipv6,
- SinkholeDomain: cfg.Domain,
- SinkholeTXT: cfg.TXT,
- TTL: cfg.TTL,
- Compress: cfg.Compress,
- Capture: cfg.Capture,
- }
- if err := conf.Validate(); err != nil {
- return dnsserver.Config{}, fmt.Errorf("dns: %w", err)
- }
- return conf, nil
-}
-
-func httpConfig(cfg appconfig.HTTPConfig) (httpserver.Config, error) {
- listen, err := netx.ParseAddr(cfg.Listen)
- if err != nil {
- return httpserver.Config{}, fmt.Errorf("http.listen: %w", err)
- }
- conf := httpserver.Config{
- Addr: listen,
- StatusCode: cfg.Status,
- Mode: cfg.Mode,
- RootDir: cfg.RootDir,
- Capture: cfg.Capture,
- }
- if err := conf.Validate(); err != nil {
- return httpserver.Config{}, fmt.Errorf("http: %w", err)
- }
- return conf, nil
-}
-
-func httpsConfig(cfg appconfig.HTTPSConfig, configDir string) (httpserver.Config, error) {
- listen, err := netx.ParseAddr(cfg.Listen)
- if err != nil {
- return httpserver.Config{}, fmt.Errorf("https.listen: %w", err)
- }
- certPath := cfg.Cert
- keyPath := cfg.Key
- if certPath == "" && keyPath == "" {
- certPath, keyPath = tlsprovider.DefaultPaths(configDir)
- }
- conf := httpserver.Config{
- Addr: listen,
- StatusCode: cfg.Status,
- Mode: cfg.Mode,
- RootDir: cfg.RootDir,
- Capture: cfg.Capture,
- TLS: &tlsprovider.Config{CertFile: certPath, KeyFile: keyPath},
- }
- if err := conf.Validate(); err != nil {
- return httpserver.Config{}, fmt.Errorf("https: %w", err)
- }
- return conf, nil
-}
diff --git a/cmd/spec.go b/cmd/spec.go
new file mode 100644
index 0000000..263fd86
--- /dev/null
+++ b/cmd/spec.go
@@ -0,0 +1,87 @@
+package cmd
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/lachlanharrisdev/gonetsim/internal/network"
+)
+
+// spec is a parsed inline listener target, e.g. "irc.lua@:6667/udp".
+type spec struct {
+ name string
+ network string // "tcp" or "udp"
+ addr string
+ handlerSpec string
+}
+
+func parseSpecs(args []string) ([]spec, error) {
+ out := make([]spec, 0, len(args))
+ for _, arg := range args {
+ s, err := parseSpec(arg)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+func parseSpec(arg string) (spec, error) {
+ i := strings.LastIndex(arg, "@")
+ if i <= 0 || i == len(arg)-1 {
+ return spec{}, fmt.Errorf("invalid listener %q (expected handler@addr, e.g. echo@:7777)", arg)
+ }
+ specStr, addr := arg[:i], arg[i+1:]
+
+ netw := "tcp"
+ if base, suffix, hasSuffix := strings.Cut(addr, "/"); hasSuffix {
+ switch suffix {
+ case "tcp", "udp":
+ netw = suffix
+ addr = base
+ default:
+ return spec{}, fmt.Errorf("invalid network %q in %q (must be /tcp or /udp)", suffix, arg)
+ }
+ }
+ if _, err := network.ParseAddr(addr); err != nil {
+ return spec{}, fmt.Errorf("invalid listen address in %q: %w", arg, err)
+ }
+
+ handlerSpec, name, err := resolveHandler(specStr)
+ if err != nil {
+ return spec{}, fmt.Errorf("%w in %q", err, arg)
+ }
+ return spec{name: name, network: netw, addr: addr, handlerSpec: handlerSpec}, nil
+}
+
+// resolveHandler normalises the part before "@" into a handler spec and a
+// display name. Accepts builtin:echo, lua:path, path.lua, or a bare builtin
+// name.
+func resolveHandler(spec string) (handlerSpec, name string, err error) {
+ if scheme, value, hasScheme := strings.Cut(spec, ":"); hasScheme {
+ switch scheme {
+ case "builtin":
+ if value == "" {
+ return "", "", fmt.Errorf("empty builtin handler %q", spec)
+ }
+ return spec, value, nil
+ case "lua":
+ if value == "" {
+ return "", "", fmt.Errorf("empty lua script %q", spec)
+ }
+ return spec, luaName(value), nil
+ default:
+ return "", "", fmt.Errorf("unknown handler scheme %q (must be builtin or lua)", scheme)
+ }
+ }
+ if strings.HasSuffix(spec, ".lua") {
+ return "lua:" + spec, luaName(spec), nil
+ }
+ return "builtin:" + spec, spec, nil
+}
+
+func luaName(path string) string {
+ return strings.TrimSuffix(filepath.Base(path), ".lua")
+}
diff --git a/cmd/targets.go b/cmd/targets.go
deleted file mode 100644
index a98a420..0000000
--- a/cmd/targets.go
+++ /dev/null
@@ -1,374 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "log/slog"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/dnsserver"
- "github.com/lachlanharrisdev/gonetsim/internal/httpserver"
- "github.com/lachlanharrisdev/gonetsim/internal/listener"
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
- "github.com/lachlanharrisdev/gonetsim/internal/service"
- "github.com/lachlanharrisdev/gonetsim/internal/state"
- "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider"
-)
-
-var presetNames = []string{"dns", "http", "https"}
-
-type targetKind int
-
-const (
- targetPreset targetKind = iota
- targetListener
- targetInline
-)
-
-type targetSpec struct {
- raw string
- kind targetKind
- preset string // targetPreset
- name string // targetListener
- inline listener.Config // targetInline
-}
-
-func parseTargets(args []string) ([]targetSpec, error) {
- out := make([]targetSpec, 0, len(args))
- for _, arg := range args {
- spec, err := parseTarget(arg)
- if err != nil {
- return nil, err
- }
- out = append(out, spec)
- }
- return out, nil
-}
-
-func parseTarget(arg string) (targetSpec, error) {
- if strings.Contains(arg, "@") {
- return parseInlineTarget(arg)
- }
- for _, p := range presetNames {
- if arg == p {
- return targetSpec{raw: arg, kind: targetPreset, preset: p}, nil
- }
- }
- return targetSpec{raw: arg, kind: targetListener, name: arg}, nil
-}
-
-func parseInlineTarget(arg string) (targetSpec, error) {
- i := strings.LastIndex(arg, "@")
- if i <= 0 || i == len(arg)-1 {
- return targetSpec{}, fmt.Errorf("invalid listener %q (expected handler@addr, e.g. echo@:7777)", arg)
- }
- spec, addr := arg[:i], arg[i+1:]
-
- network := "tcp"
- if base, suffix, hasSuffix := strings.Cut(addr, "/"); hasSuffix {
- switch suffix {
- case "tcp", "udp":
- network = suffix
- addr = base
- default:
- return targetSpec{}, fmt.Errorf("invalid network %q in %q (must be /tcp or /udp)", suffix, arg)
- }
- }
- if _, err := netx.ParseAddr(addr); err != nil {
- return targetSpec{}, fmt.Errorf("invalid listen address in %q: %w", arg, err)
- }
-
- handlerSpec, name, err := resolveInlineHandler(spec)
- if err != nil {
- return targetSpec{}, fmt.Errorf("%w in %q", err, arg)
- }
-
- return targetSpec{
- raw: arg,
- kind: targetInline,
- inline: listener.Config{
- Name: name,
- Network: network,
- Addr: addr,
- HandlerSpec: handlerSpec,
- Capture: true,
- },
- }, nil
-}
-
-func resolveInlineHandler(spec string) (string, string, error) {
- if scheme, value, hasScheme := strings.Cut(spec, ":"); hasScheme {
- switch scheme {
- case "builtin":
- if value == "" {
- return "", "", fmt.Errorf("empty builtin handler %q", spec)
- }
- return spec, value, nil
- case "lua":
- if value == "" {
- return "", "", fmt.Errorf("empty lua script %q", spec)
- }
- return spec, luaName(value), nil
- default:
- return "", "", fmt.Errorf("unknown handler scheme %q (must be builtin or lua)", scheme)
- }
- }
- if strings.HasSuffix(spec, ".lua") {
- return "lua:" + spec, luaName(spec), nil
- }
- return "builtin:" + spec, spec, nil
-}
-
-func luaName(path string) string {
- return strings.TrimSuffix(filepath.Base(path), ".lua")
-}
-
-func parseSets(sets []string) (map[string]any, error) {
- out := make(map[string]any, len(sets))
- for _, s := range sets {
- key, value, ok := strings.Cut(s, "=")
- if !ok || strings.TrimSpace(key) == "" {
- return nil, fmt.Errorf("invalid --set %q (expected key=value)", s)
- }
- switch value {
- case "true":
- out[key] = true
- case "false":
- out[key] = false
- default:
- if n, err := strconv.Atoi(value); err == nil {
- out[key] = n
- } else {
- out[key] = value
- }
- }
- }
- return out, nil
-}
-
-type resolvedTarget struct {
- svc service.Service
- display string
-}
-
-func resolveTargets(specs []targetSpec, cfg *appconfig.Config, configDir, cwd string, opts runOptions, logger *slog.Logger, global *state.Store, run *capture.Run) ([]resolvedTarget, error) {
- if len(specs) == 0 {
- return resolveAll(cfg, configDir, opts, logger, global, run)
- }
-
- if opts.listen != "" && len(specs) > 1 {
- return nil, fmt.Errorf("--listen requires exactly one target, got %d", len(specs))
- }
-
- out := make([]resolvedTarget, 0, len(specs))
- for _, spec := range specs {
- rt, err := resolveOne(spec, cfg, configDir, cwd, opts, logger, global, run)
- if err != nil {
- return nil, err
- }
- out = append(out, rt)
- }
- return out, nil
-}
-
-func resolveAll(cfg *appconfig.Config, configDir string, opts runOptions, logger *slog.Logger, global *state.Store, run *capture.Run) ([]resolvedTarget, error) {
- out := make([]resolvedTarget, 0, len(presetTargets)+len(cfg.Listeners))
- for _, p := range presetTargets {
- if !p.enabled(cfg) {
- continue
- }
- svc, display, err := p.build(cfg, configDir, opts, logger, run)
- if err != nil {
- return nil, err
- }
- out = append(out, resolvedTarget{svc: svc, display: display})
- }
-
- for _, l := range cfg.Listeners {
- if !l.IsEnabled() {
- continue
- }
- rt, err := resolveOne(targetSpec{raw: l.Name, kind: targetListener, name: l.Name}, cfg, configDir, "", opts, logger, global, run)
- if err != nil {
- return nil, err
- }
- out = append(out, rt)
- }
- return out, nil
-}
-
-func resolveOne(spec targetSpec, cfg *appconfig.Config, configDir, cwd string, opts runOptions, logger *slog.Logger, global *state.Store, run *capture.Run) (resolvedTarget, error) {
- switch spec.kind {
- case targetPreset:
- for _, p := range presetTargets {
- if p.name != spec.preset {
- continue
- }
- svc, display, err := p.build(cfg, configDir, opts, logger, run)
- if err != nil {
- return resolvedTarget{}, err
- }
- return resolvedTarget{svc: svc, display: display}, nil
- }
- return resolvedTarget{}, fmt.Errorf("unknown target %q (available: %s)", spec.raw, availableTargets(cfg))
-
- case targetListener:
- var entry *appconfig.ListenerConfig
- for i := range cfg.Listeners {
- if cfg.Listeners[i].Name == spec.name {
- entry = &cfg.Listeners[i]
- break
- }
- }
- if entry == nil {
- return resolvedTarget{}, fmt.Errorf("unknown target %q (available: %s)", spec.raw, availableTargets(cfg))
- }
- conf, err := listenerConfig(*entry, configDir)
- if err != nil {
- return resolvedTarget{}, err
- }
- if err := applyListenerRunOptions(&conf, opts, opts.listen); err != nil {
- return resolvedTarget{}, err
- }
- return buildListener(conf, global, logger, run)
-
- case targetInline:
- conf := spec.inline
- conf.BaseDir = cwd
- if err := applyListenerRunOptions(&conf, opts, opts.listen); err != nil {
- return resolvedTarget{}, err
- }
- return buildListener(conf, global, logger, run)
-
- default:
- return resolvedTarget{}, fmt.Errorf("unknown target kind %d", spec.kind)
- }
-}
-
-func applyCaptureOptions(noCapture bool, capture *bool) {
- if noCapture {
- *capture = false
- }
-}
-
-func applyListenerRunOptions(conf *listener.Config, opts runOptions, listen string) error {
- if listen != "" {
- conf.Addr = listen
- }
- if conf.ReadTimeout <= 0 {
- conf.ReadTimeout = defaultReadTimeout
- }
- if opts.timeout > 0 {
- conf.ReadTimeout = opts.timeout
- }
- if opts.noCapture {
- conf.Capture = false
- }
- if opts.tls {
- if conf.Network != "tcp" {
- return fmt.Errorf("listener %s: --tls requires a tcp listener", conf.Name)
- }
- // empty config requires an ephemeral self signed cert
- conf.TLS = &tlsprovider.Config{}
- }
- return nil
-}
-
-func buildListener(conf listener.Config, global *state.Store, logger *slog.Logger, run *capture.Run) (resolvedTarget, error) {
- svc, err := listener.NewService(conf, global, logger, run)
- if err != nil {
- return resolvedTarget{}, err
- }
- return resolvedTarget{svc: svc, display: listenerDisplay(conf)}, nil
-}
-
-func listenerDisplay(conf listener.Config) string {
- display := conf.Name + "(" + conf.Addr
- if conf.Network == "udp" {
- display += "/udp"
- }
- if conf.TLS != nil {
- display += "+tls"
- }
- return display + ")"
-}
-
-func presetBuild[AC any, SC any](
- appCfg AC,
- opts runOptions,
- configDir string,
- logger *slog.Logger,
- run *capture.Run,
- setListen func(*AC, string),
- parse func(AC, string) (SC, error),
- applyCapture func(*SC),
- svc func(SC, *slog.Logger, *capture.Run) service.Service,
- display func(SC) string,
-) (service.Service, string, error) {
- if opts.listen != "" {
- setListen(&appCfg, opts.listen)
- }
- conf, err := parse(appCfg, configDir)
- if err != nil {
- return nil, "", err
- }
- applyCapture(&conf)
- return svc(conf, logger, run), display(conf), nil
-}
-
-var presetTargets = []struct {
- name string
- enabled func(c *appconfig.Config) bool
- build func(c *appconfig.Config, configDir string, opts runOptions, logger *slog.Logger, run *capture.Run) (service.Service, string, error)
-}{
- {
- name: "dns",
- enabled: func(c *appconfig.Config) bool { return c.DNS.Enabled },
- build: func(c *appconfig.Config, configDir string, opts runOptions, logger *slog.Logger, run *capture.Run) (service.Service, string, error) {
- return presetBuild(c.DNS, opts, configDir, logger, run,
- func(a *appconfig.DNSConfig, l string) { a.Listen = l },
- func(a appconfig.DNSConfig, _ string) (dnsserver.Config, error) { return dnsConfig(a) },
- func(s *dnsserver.Config) { applyCaptureOptions(opts.noCapture, &s.Capture) },
- dnsserver.NewService,
- func(s dnsserver.Config) string { return fmt.Sprintf("dns(%s/%s)", s.Addr, netx.DisplayNetwork(s.Net)) },
- )
- },
- },
- {
- name: "http",
- enabled: func(c *appconfig.Config) bool { return c.HTTP.Enabled },
- build: func(c *appconfig.Config, configDir string, opts runOptions, logger *slog.Logger, run *capture.Run) (service.Service, string, error) {
- return presetBuild(c.HTTP, opts, configDir, logger, run,
- func(a *appconfig.HTTPConfig, l string) { a.Listen = l },
- func(a appconfig.HTTPConfig, _ string) (httpserver.Config, error) { return httpConfig(a) },
- func(s *httpserver.Config) { applyCaptureOptions(opts.noCapture, &s.Capture) },
- httpserver.NewService,
- func(s httpserver.Config) string { return fmt.Sprintf("http(%s)", s.Addr) },
- )
- },
- },
- {
- name: "https",
- enabled: func(c *appconfig.Config) bool { return c.HTTPS.Enabled },
- build: func(c *appconfig.Config, configDir string, opts runOptions, logger *slog.Logger, run *capture.Run) (service.Service, string, error) {
- return presetBuild(c.HTTPS, opts, configDir, logger, run,
- func(a *appconfig.HTTPSConfig, l string) { a.Listen = l },
- func(a appconfig.HTTPSConfig, dir string) (httpserver.Config, error) { return httpsConfig(a, dir) },
- func(s *httpserver.Config) { applyCaptureOptions(opts.noCapture, &s.Capture) },
- httpserver.NewService,
- func(s httpserver.Config) string { return fmt.Sprintf("https(%s)", s.Addr) },
- )
- },
- },
-}
-
-func availableTargets(cfg *appconfig.Config) string {
- names := append([]string{}, presetNames...)
- for _, l := range cfg.Listeners {
- names = append(names, l.Name)
- }
- return fmt.Sprintf("%s; or handler@addr for an inline listener, e.g. echo@:7777", strings.Join(names, ", "))
-}
diff --git a/cmd/targets_test.go b/cmd/targets_test.go
deleted file mode 100644
index 69e2793..0000000
--- a/cmd/targets_test.go
+++ /dev/null
@@ -1,267 +0,0 @@
-package cmd
-
-import (
- "log/slog"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/state"
- "github.com/lachlanharrisdev/gonetsim/internal/testutil"
-)
-
-func testLogger() *slog.Logger {
- return testutil.Logger()
-}
-
-func disabledAll(cfg *appconfig.Config) {
- cfg.DNS.Enabled = false
- cfg.HTTP.Enabled = false
- cfg.HTTPS.Enabled = false
-}
-
-func TestParseTargets(t *testing.T) {
- for _, name := range presetNames {
- spec, err := parseTarget(name)
- if err != nil || spec.kind != targetPreset || spec.preset != name {
- t.Fatalf("parseTarget(%q) = %+v, %v; want preset", name, spec, err)
- }
- }
-
- spec, err := parseTarget("irc")
- if err != nil || spec.kind != targetListener || spec.name != "irc" {
- t.Fatalf("parseTarget(irc) = %+v, %v; want listener", spec, err)
- }
-
- cases := []struct {
- arg string
- handlerSpec string
- name string
- network string
- addr string
- }{
- {"echo@:7777", "builtin:echo", "echo", "tcp", ":7777"},
- {"sink@:9999/udp", "builtin:sink", "sink", "udp", ":9999"},
- {"echo@:1/tcp", "builtin:echo", "echo", "tcp", ":1"},
- {"builtin:echo@:7777", "builtin:echo", "echo", "tcp", ":7777"},
- {"lua:c2.lua@:8080", "lua:c2.lua", "c2", "tcp", ":8080"},
- {"c2.lua@:8080", "lua:c2.lua", "c2", "tcp", ":8080"},
- {"handlers/sub/irc.lua@127.0.0.1:6667", "lua:handlers/sub/irc.lua", "irc", "tcp", "127.0.0.1:6667"},
- }
- for _, tc := range cases {
- spec, err := parseTarget(tc.arg)
- if err != nil || spec.kind != targetInline {
- t.Fatalf("parseTarget(%q) = %+v, %v; want inline", tc.arg, spec, err)
- }
- got := spec.inline
- if got.HandlerSpec != tc.handlerSpec || got.Name != tc.name || got.Network != tc.network || got.Addr != tc.addr {
- t.Fatalf("parseTarget(%q) = %+v; want spec=%s name=%s net=%s addr=%s",
- tc.arg, got, tc.handlerSpec, tc.name, tc.network, tc.addr)
- }
- }
-
- for _, arg := range []string{
- "@", ":7777@", "echo@",
- "echo@:7777/sctp", "echo@notanaddr", "smtp:foo@:8080", "lua:@:8080", "builtin:@:8080",
- } {
- if _, err := parseTarget(arg); err == nil {
- t.Errorf("parseTarget(%q): expected error", arg)
- }
- }
-}
-
-func TestParseSets(t *testing.T) {
- overrides, err := parseSets([]string{
- "http.mode=real",
- "http.status=404",
- "smtp.require_auth=true",
- "dns.enabled=false",
- "general.shutdown_timeout=5s",
- })
- if err != nil {
- t.Fatalf("parseSets: %v", err)
- }
- if overrides["http.mode"] != "real" {
- t.Errorf("http.mode = %v (%T), want string", overrides["http.mode"], overrides["http.mode"])
- }
- if overrides["http.status"] != 404 {
- t.Errorf("http.status = %v (%T), want int", overrides["http.status"], overrides["http.status"])
- }
- if overrides["smtp.require_auth"] != true || overrides["dns.enabled"] != false {
- t.Errorf("bool overrides = %v, %v, want true/false", overrides["smtp.require_auth"], overrides["dns.enabled"])
- }
- if overrides["general.shutdown_timeout"] != "5s" {
- t.Errorf("duration override = %v (%T), want string", overrides["general.shutdown_timeout"], overrides["general.shutdown_timeout"])
- }
-
- for _, s := range []string{"noequals", "=novalue", ""} {
- if _, err := parseSets([]string{s}); err == nil {
- t.Errorf("parseSets(%q): expected error", s)
- }
- }
-}
-
-func TestResolveTargets(t *testing.T) {
- t.Run("all enabled", func(t *testing.T) {
- cfg := appconfig.Default()
- resolved := testResolve(t, &cfg, nil, runOptions{})
- if len(resolved) != len(presetNames) {
- t.Fatalf("expected %d presets, got %d", len(presetNames), len(resolved))
- }
- })
-
- t.Run("explicit target overrides enabled=false", func(t *testing.T) {
- disabled := false
- cfg := appconfig.Default()
- disabledAll(&cfg)
- cfg.Listeners = []appconfig.ListenerConfig{
- {Name: "irc", Type: "tcp", Listen: "127.0.0.1:0", Handler: "builtin:echo"},
- {Name: "off", Enabled: &disabled, Type: "tcp", Listen: "127.0.0.1:0", Handler: "builtin:sink"},
- }
-
- resolved := testResolve(t, &cfg, nil, runOptions{})
- if len(resolved) != 1 {
- t.Fatalf("expected disabled listener to be skipped, got %d targets", len(resolved))
- }
-
- resolved = testResolve(t, &cfg, []string{"off"}, runOptions{})
- if len(resolved) != 1 {
- t.Fatalf("explicit target: %d targets", len(resolved))
- }
- })
-
- t.Run("unknown target lists alternatives", func(t *testing.T) {
- cfg := appconfig.Default()
- _, err := resolveTargets(mustSpecs(t, "nope"), &cfg, t.TempDir(), t.TempDir(), runOptions{}, testLogger(), state.NewStore(nil), nil)
- if err == nil || !strings.Contains(err.Error(), "unknown target") ||
- !strings.Contains(err.Error(), "dns") || !strings.Contains(err.Error(), "handler@addr") {
- t.Fatalf("expected helpful unknown-target error, got: %v", err)
- }
- })
-
- t.Run("inline lua resolves script", func(t *testing.T) {
- cfg := appconfig.Default()
- resolved, err := resolveTargets(mustSpecs(t, "testdata/hello.lua@127.0.0.1:0"), &cfg, t.TempDir(), ".", runOptions{}, testLogger(), state.NewStore(nil), nil)
- if err != nil || len(resolved) != 1 || resolved[0].display != "hello(127.0.0.1:0)" {
- t.Fatalf("inline lua: %v, %+v", err, resolved)
- }
- })
-
- t.Run("tls on udp rejected", func(t *testing.T) {
- cfg := appconfig.Default()
- _, err := resolveTargets(mustSpecs(t, "sink@:0/udp"), &cfg, t.TempDir(), t.TempDir(), runOptions{tls: true}, testLogger(), state.NewStore(nil), nil)
- if err == nil {
- t.Fatalf("expected --tls on udp to be rejected")
- }
- })
-
- t.Run("listen requires single target", func(t *testing.T) {
- cfg := appconfig.Default()
- opts := runOptions{listen: "127.0.0.1:1234"}
- _, err := resolveTargets(mustSpecs(t, "http", "dns"), &cfg, t.TempDir(), t.TempDir(), opts, testLogger(), state.NewStore(nil), nil)
- if err == nil {
- t.Fatalf("expected --listen with multiple targets to fail")
- }
- })
-}
-
-func TestServiceConfigMapping(t *testing.T) {
- t.Run("preset names match builders", func(t *testing.T) {
- if len(presetTargets) != len(presetNames) {
- t.Fatalf("presetTargets has %d entries, presetNames has %d", len(presetTargets), len(presetNames))
- }
- for i, p := range presetTargets {
- if p.name != presetNames[i] || p.enabled == nil || p.build == nil {
- t.Fatalf("presetTargets[%d] = %q, want %q with enabled/build set", i, p.name, presetNames[i])
- }
- }
- })
-
- t.Run("listener type and timeout defaults", func(t *testing.T) {
- l := appconfig.ListenerConfig{Name: "x", Listen: "127.0.0.1:0", Handler: "builtin:echo"}
- conf, err := listenerConfig(l, t.TempDir())
- if err != nil {
- t.Fatalf("listenerConfig: %v", err)
- }
- if conf.Network != "tcp" {
- t.Fatalf("expected type to default to tcp, got %q", conf.Network)
- }
- if conf.ReadTimeout != defaultReadTimeout {
- t.Fatalf("expected default read timeout, got %v", conf.ReadTimeout)
- }
- })
-
- t.Run("dns auto ipv4", func(t *testing.T) {
- cfg := appconfig.DNSConfig{
- ServiceBase: appconfig.ServiceBase{Listen: "127.0.0.1:0"},
- Network: "udp",
- IPv4: "auto",
- Domain: "localhost",
- TXT: "test",
- }
- conf, err := dnsConfig(cfg)
- if err != nil {
- t.Fatalf("dnsConfig(auto): %v", err)
- }
- if !conf.SinkholeIPv4.IsValid() || !conf.SinkholeIPv4.Is4() {
- t.Fatalf("expected auto-detected IPv4, got %v", conf.SinkholeIPv4)
- }
-
- cfg.IPv4 = "AUTO"
- if _, err := dnsConfig(cfg); err != nil {
- t.Fatalf("dnsConfig(AUTO): %v", err)
- }
- cfg.IPv4 = "203.0.113.10"
- conf, err = dnsConfig(cfg)
- if err != nil {
- t.Fatalf("dnsConfig(explicit): %v", err)
- }
- if conf.SinkholeIPv4.String() != "203.0.113.10" {
- t.Fatalf("expected explicit IP, got %v", conf.SinkholeIPv4)
- }
- })
-}
-
-func mustSpecs(t *testing.T, args ...string) []targetSpec {
- t.Helper()
- specs, err := parseTargets(args)
- if err != nil {
- t.Fatalf("parseTargets(%v): %v", args, err)
- }
- return specs
-}
-
-func testResolve(t *testing.T, cfg *appconfig.Config, args []string, opts runOptions) []resolvedTarget {
- t.Helper()
- var specs []targetSpec
- if len(args) > 0 {
- specs = mustSpecs(t, args...)
- }
- resolved, err := resolveTargets(specs, cfg, t.TempDir(), t.TempDir(), opts, testLogger(), state.NewStore(nil), nil)
- if err != nil {
- t.Fatalf("resolveTargets(%v): %v", args, err)
- }
- return resolved
-}
-
-func TestCheckRunDir(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_DATA_HOME", dir)
-
- if err := checkRunDir(); err != nil {
- t.Fatalf("checkRunDir: %v", err)
- }
- runs, err := capture.DefaultRunsDir()
- if err != nil {
- t.Fatalf("DefaultRunsDir: %v", err)
- }
- if st, err := os.Stat(runs); err != nil || !st.IsDir() {
- t.Fatalf("expected runs dir to exist: %v", err)
- }
- if filepath.Dir(runs) != filepath.Join(dir, "gonetsim") {
- t.Fatalf("runs dir = %q, want it under %q", runs, dir)
- }
-}
diff --git a/cmd/testdata/hello.lua b/cmd/testdata/hello.lua
deleted file mode 100644
index 5cd634d..0000000
--- a/cmd/testdata/hello.lua
+++ /dev/null
@@ -1,4 +0,0 @@
-function handle(conn)
- local line = conn:read_line()
- conn:write("hello: " .. line)
-end
diff --git a/cmd/tls.go b/cmd/tls.go
index b0b16bf..dac4a4c 100644
--- a/cmd/tls.go
+++ b/cmd/tls.go
@@ -10,65 +10,64 @@ import (
"path/filepath"
"time"
- appconfig "github.com/lachlanharrisdev/gonetsim/internal/config"
- "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider"
"github.com/spf13/cobra"
+
+ "github.com/lachlanharrisdev/gonetsim/internal/tlscert"
)
-var tlsVerifyOnly bool
-var tlsForce bool
+var (
+ tlsDir string
+ tlsForce bool
+ tlsVerifyOnly bool
+)
var tlsCmd = &cobra.Command{
Use: "tls",
Short: "Generate and verify persisted TLS certificates",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfgRes, err := appconfig.LoadOrCreate(rootConfigPath)
- if err != nil {
- return err
- }
- configDir := filepath.Dir(cfgRes.Path)
-
- certPath := filepath.Join(configDir, tlsprovider.PersistedCertFileName)
- keyPath := filepath.Join(configDir, tlsprovider.PersistedKeyFileName)
- caPath := filepath.Join(configDir, tlsprovider.PersistedCAFileName)
-
- conf := &tlsprovider.Config{CertFile: certPath, KeyFile: keyPath}
-
- if tlsForce {
- if err := conf.Regenerate(); err != nil {
- return err
- }
- }
+ RunE: runTLS,
+}
- if !tlsVerifyOnly {
- if _, err := conf.TLSConfig(); err != nil {
- return err
- }
- }
+func init() {
+ rootCmd.AddCommand(tlsCmd)
+ flags := tlsCmd.Flags()
+ flags.StringVar(&tlsDir, "dir", "./tls", "directory to store the certificate pair and CA")
+ flags.BoolVar(&tlsForce, "force", false, "regenerate the certificate pair even if it exists")
+ flags.BoolVar(&tlsVerifyOnly, "verify-only", false, "verify existing files without generating")
+}
- if err := verifyKeyPair(certPath, keyPath, caPath); err != nil {
- return err
- }
+func runTLS(cmd *cobra.Command, _ []string) error {
+ if err := os.MkdirAll(tlsDir, 0o755); err != nil {
+ return err
+ }
+ certPath := filepath.Join(tlsDir, tlscert.PersistedCertFileName)
+ keyPath := filepath.Join(tlsDir, tlscert.PersistedKeyFileName)
+ caPath := filepath.Join(tlsDir, tlscert.PersistedCAFileName)
- expIRY, err := certExpiry(certPath)
- if err != nil {
+ conf := tlscert.Config{CertFile: certPath, KeyFile: keyPath}
+ if tlsForce {
+ if err := conf.Regenerate(); err != nil {
return err
}
-
- if _, err := fmt.Fprintf(cmd.OutOrStdout(), "TLS OK\ncert: %s\nkey: %s\nca: %s\nexpiry: %s\n", certPath, keyPath, caPath, expIRY.Format(time.RFC3339)); err != nil {
+ }
+ if !tlsVerifyOnly {
+ if _, err := conf.TLSConfig(); err != nil {
return err
}
- return nil
- },
-}
+ }
+ if err := verifyKeyPair(certPath, keyPath, caPath); err != nil {
+ return err
+ }
+ expiry, err := certExpiry(certPath)
+ if err != nil {
+ return err
+ }
-func init() {
- rootCmd.AddCommand(tlsCmd)
- tlsCmd.Flags().BoolVar(&tlsVerifyOnly, "verify-only", false, "verify existing files without generating")
- tlsCmd.Flags().BoolVar(&tlsForce, "force", false, "regenerate the persisted certificate pair even if it already exists")
+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "TLS OK\ncert: %s\nkey: %s\nca: %s\nexpiry: %s\n",
+ certPath, keyPath, caPath, expiry.Format(time.RFC3339))
+ return err
}
-// certExpiry returns the NotAfter time of the leaf certificate at certPath
+// certExpiry returns the NotAfter time of the leaf certificate at certPath.
func certExpiry(certPath string) (time.Time, error) {
raw, err := os.ReadFile(certPath)
if err != nil {
@@ -117,9 +116,6 @@ func verifyKeyPair(certPath, keyPath, caPath string) error {
if leaf.KeyUsage&(x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment) == 0 {
return fmt.Errorf("unexpected leaf KeyUsage: %v", leaf.KeyUsage)
}
- if len(leaf.ExtKeyUsage) == 0 {
- return errors.New("leaf ExtKeyUsage is empty")
- }
serverAuth := false
for _, eku := range leaf.ExtKeyUsage {
if eku == x509.ExtKeyUsageServerAuth {
@@ -131,7 +127,6 @@ func verifyKeyPair(certPath, keyPath, caPath string) error {
return errors.New("leaf ExtKeyUsage does not include ServerAuth")
}
- // If a CA file exists, ensure the leaf verifies against it.
caBytes, err := os.ReadFile(caPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -146,6 +141,5 @@ func verifyKeyPair(certPath, keyPath, caPath string) error {
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil {
return fmt.Errorf("leaf does not verify against CA: %w", err)
}
-
return nil
}
diff --git a/cmd/version.go b/cmd/version.go
index 46730a5..4373a6a 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -13,25 +13,14 @@ var (
Date = "unknown"
)
-func GetVersion() string {
- return fmt.Sprintf(`Version: %s
-Revision: %s
-Date: %s
-OS: %s
-Arch: %s`, Version, Revision, Date, runtime.GOOS, runtime.GOARCH)
-}
-
-// alternative that returns a single line string
-func GetVersionLine() string {
- return fmt.Sprintf("%s (%s %s)", Version, runtime.GOOS, runtime.GOARCH)
-}
-
var versionCmd = &cobra.Command{
- Use: "version",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Println(GetVersion())
- },
+ Use: "version",
Short: "Show version info",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "Version: %s\nRevision: %s\nDate: %s\nOS: %s\nArch: %s\n",
+ Version, Revision, Date, runtime.GOOS, runtime.GOARCH)
+ return err
+ },
}
func init() {
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..fecbee4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,19 @@
+# The image is built on main by CI (ghcr.io/lachlanharrisdev/gonetsim:latest)
+# or locally with:
+# KO_DOCKER_REPO=gonetsim ko build --local --bare --tags=dev .
+
+services:
+ gonetsim:
+ image: ghcr.io/lachlanharrisdev/gonetsim:latest
+ command:
+ - /handlers/irc.lua@:6667
+ - --pcap
+ - /runs/case.pcapng
+ volumes:
+ - ./handlers:/handlers:ro
+ - ./runs:/runs
+ ports:
+ - "6667:6667"
+ # add more listeners, e.g.:
+ # - "/handlers/smtp.lua@:2525"
+ # - "sink@:9999/udp"
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
deleted file mode 100644
index 566e000..0000000
--- a/docker/docker-compose.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-services:
- gonetsim:
- # build locally with `KO_DOCKER_REPO=gonetsim ko build --local --bare --tags=dev .`
- # then set image: gonetsim:dev
- image: ghcr.io/lachlanharrisdev/gonetsim:latest
- ports:
- - "5353:53/udp"
- - "5353:53/tcp"
- - "8080:80"
- - "8443:443"
- # volumes:
- # - ../gonetsim.toml:/etc/gonetsim/gonetsim.toml:ro
- # - ../handlers:/etc/gonetsim/handlers:ro
- # - ./captures:/root/.local/share/gonetsim/runs
diff --git a/docs/capture.md b/docs/capture.md
new file mode 100644
index 0000000..fbb85b8
--- /dev/null
+++ b/docs/capture.md
@@ -0,0 +1,55 @@
+# Capture
+
+Every run writes a single pcapng file. GoNetSim synthesises Ethernet, IP and TCP
+or UDP frames from the bytes your handler reads and writes. Nothing is sniffed
+from a real interface. The file opens in Wireshark, tshark, or any pcapng
+reader.
+
+## Capture Storage
+
+Without `--pcap`, the capture is written to `./.pcapng` in the current
+directory, where `` is a timestamp. Pass `--pcap path` to choose the
+location, or `--no-pcap` to write no capture. The terminal prints the path and
+the packet count when you stop the simulator.
+
+## Annotating packets
+
+A handler can attach a note to the packet it just read or wrote.
+
+```lua
+capture:comment("C2 beacon key exchange")
+```
+
+The note appears on that packet in Wireshark. Comments longer than 256 bytes are
+truncated.
+
+## Decrypting TLS
+
+TLS listeners are captured as ciphertext. To make the capture readable:
+
+1. Save the session keys and point Wireshark at them.
+
+```sh
+gonetsim handlers/irc.lua@:6667 --tls --tls-keylog keys.log
+```
+
+2. In Wireshark, open Edit, Preferences, Protocols, TLS and set
+(Pre)-Master-Secret log filename to `keys.log`, then reopen the capture
+
+`keys.log` is the standard SSLKEYLOGFILE format written by Go's TLS library.
+Without `--tls-keylog`, Wireshark cannot decrypt a TLS capture and GoNetSim
+will print a warning at startup
+
+## Troubleshooting
+
+If the capture still shows garbled application data after you set the keylog
+file, check these before you reopen:
+
+- The keylog file is not empty and holds four secrets per TLS 1.3 session:
+ `CLIENT_HANDSHAKE_TRAFFIC_SECRET`, `SERVER_HANDSHAKE_TRAFFIC_SECRET`,
+ `CLIENT_TRAFFIC_SECRET_0` and `SERVER_TRAFFIC_SECRET_0`.
+- Wireshark 3.2 or newer can decrypt TLS 1.3 captures. Older versions only
+ understand the TLS 1.2 `CLIENT_RANDOM` line.
+- The (Pre)-Master-Secret log filename preference is applied and the capture is
+ reopened after you set it. Wireshark reads the keylog when it sees the
+ session, so loading the file after opening the capture is not enough.
\ No newline at end of file
diff --git a/docs/docfs.go b/docs/docfs.go
new file mode 100644
index 0000000..e72c798
--- /dev/null
+++ b/docs/docfs.go
@@ -0,0 +1,57 @@
+// Package docs embeds the repository markdown so `gonetsim docs `
+// works fully offline (air-gapped labs, Windows analyst boxes, anywhere).
+// These same files are the source for the live website, so one edit lands in
+// both the binary and the site.
+package docs
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "sort"
+ "strings"
+)
+
+//go:embed *.md
+var fsys embed.FS
+
+// docOrder is the reading order for `gonetsim docs`. Topics not listed here are
+// appended in sorted order, so a new markdown file still surfaces on its own.
+var docOrder = []string{"quickstart", "flags", "capture", "lua-api", "handlers", "tls-airgap"}
+
+// Names lists the available doc topics in reading order.
+func Names() []string {
+ entries, err := fs.ReadDir(fsys, ".")
+ if err != nil {
+ return nil
+ }
+ present := make(map[string]bool, len(entries))
+ for _, e := range entries {
+ name := strings.TrimSuffix(e.Name(), ".md")
+ if name != "" {
+ present[name] = true
+ }
+ }
+ names := make([]string, 0, len(present))
+ for _, n := range docOrder {
+ if present[n] {
+ names = append(names, n)
+ delete(present, n)
+ }
+ }
+ extra := make([]string, 0, len(present))
+ for n := range present {
+ extra = append(extra, n)
+ }
+ sort.Strings(extra)
+ return append(names, extra...)
+}
+
+// Read returns the markdown content for a topic.
+func Read(topic string) (string, error) {
+ data, err := fs.ReadFile(fsys, topic+".md")
+ if err != nil {
+ return "", fmt.Errorf("unknown docs topic %q (see: gonetsim docs)", topic)
+ }
+ return string(data), nil
+}
diff --git a/docs/flags.md b/docs/flags.md
new file mode 100644
index 0000000..94e05f1
--- /dev/null
+++ b/docs/flags.md
@@ -0,0 +1,48 @@
+# Flags
+
+GoNetSim has no config file. Every option is a positional target or a flag.
+
+## Targets
+
+Each positional argument is `handler@addr[/tcp|/udp]`. TCP is the default
+transport. The script path is resolved relative to the current directory.
+
+| Target | Meaning |
+|---|---|
+| `echo@:7777` | built-in echo on TCP port 7777 |
+| `sink@:9999/udp` | built-in sink on UDP port 9999 |
+| `handlers/irc.lua@:6667` | the Lua file `handlers/irc.lua` on TCP port 6667 |
+| `lua:handlers/irc.lua@:6667` | the same handler, written with an explicit scheme |
+
+## Listener flags
+
+| Flag | Meaning |
+|---|---|
+| `--listen addr` | override the listen address; requires exactly one target |
+| `--timeout t` | connection idle timeout, default 30s; 0 disables it. Accepts seconds (`5`) or a duration (`5s`, `1m30s`) |
+| `--tls` | wrap TCP listeners in TLS |
+| `--tls-cert f` | TLS certificate file; used with `--tls` |
+| `--tls-key f` | TLS key file; used with `--tls` |
+
+## Capture flags
+
+| Flag | Meaning |
+|---|---|
+| `--pcap f` | write the capture to f; default `./.pcapng` |
+| `--no-pcap` | do not write a capture |
+| `--tls-keylog f` | write TLS session keys to f in SSLKEYLOGFILE format so Wireshark can decrypt the capture; requires `--tls` |
+
+## Logging flags
+
+| Flag | Meaning |
+|---|---|
+| `--log-level l` | one of debug, info, warn, error; default info |
+| `--log-format f` | `text` (default) or `json`. Human output never uses key=value fields. json emits `{time, level, msg}` lines |
+
+## Subcommands
+
+| Command | Meaning |
+|---|---|
+| `gonetsim tls` | generate or verify a persisted certificate pair and CA |
+| `gonetsim docs [topic]` | print built-in documentation; works offline |
+| `gonetsim version` | print version information |
diff --git a/docs/handlers.md b/docs/handlers.md
new file mode 100644
index 0000000..7cfdd7e
--- /dev/null
+++ b/docs/handlers.md
@@ -0,0 +1,74 @@
+# Writing a handler
+
+A handler is a plain Lua file that speaks the server side of a protocol. It runs
+sandboxed, so it cannot read files or touch the host. This page walks through a
+complete TCP handler. For the full API and the sandbox rules, see
+`gonetsim docs lua-api`.
+
+## Pick an entry point
+
+Define `handle(conn)` for a TCP listener. The function is called once per
+connection and returns when the connection is done.
+
+Define `handle_packet(data, peer)` for a UDP listener. It returns the reply
+string, or nil to send nothing.
+
+## Read, write, and log
+
+The example below opens with a banner, reads lines until the client hangs up,
+and logs every line it received.
+
+```lua
+function handle(conn)
+ conn:write("220 example ready\r\n")
+ while true do
+ local line = conn:read_line()
+ if line == nil then
+ break
+ end
+ log:info("client said", line)
+ conn:write("250 ok\r\n")
+ end
+end
+```
+
+`conn:read_line` returns nil on a clean end of stream, which ends the loop. Send
+bytes with `conn:write`, or end a connection early with `conn:close`.
+
+## Keep state
+
+Handlers remember values through the `conn`, `handler`, and `global` stores. The
+`handler` store is shared by every connection of one listener, so it fits
+counters.
+
+```lua
+local count = tonumber(handler:get("count") or "0") + 1
+handler:set("count", tostring(count))
+conn:write("you are visitor " .. count .. "\r\n")
+```
+
+## Annotate the capture
+
+Mark an interesting packet with `capture:comment`. The note appears on that
+packet in Wireshark.
+
+```lua
+capture:comment("client finished the handshake")
+```
+
+## Test the handler
+
+Put the file in the current directory and run it on a port.
+
+```sh
+gonetsim example.lua@:12345
+```
+
+Connect with a terminal client.
+
+```sh
+nc 127.0.0.1 12345
+```
+
+Send a line and read the reply. Stop the simulator with Ctrl-C and open the
+capture in Wireshark. The terminal prints the capture path and packet count.
\ No newline at end of file
diff --git a/docs/lua-api.md b/docs/lua-api.md
new file mode 100644
index 0000000..a6c8e53
--- /dev/null
+++ b/docs/lua-api.md
@@ -0,0 +1,89 @@
+# Lua handler API
+
+A handler is a plain Lua 5.1 file. The file runs once at load time to define its
+entry points. Each connection or datagram then runs in a fresh sandboxed state,
+so one listener cannot leak state into another except through the stores below.
+
+## Entry points
+
+Define at least one of these two functions.
+
+```lua
+function handle(conn) -- TCP: called once per connection
+function handle_packet(data, peer) -- UDP: return a reply, or nil for no reply
+```
+
+`handle` returns when the connection is finished. `handle_packet` returns the
+bytes to send back, or nil to stay silent.
+
+## TCP: the conn object
+
+| Call | Meaning |
+|---|---|
+| `conn:read(n)` | read up to n bytes, or nil on a clean end of stream |
+| `conn:read_line()` | read through the next newline |
+| `conn:read_until(delim)` | read until delim |
+| `conn:write(s)` | write bytes |
+| `conn:sleep(ms)` | pause the handler; cancellable and capped at one hour |
+| `conn:close()` | close the connection |
+| `conn:remote()`, `conn:local()` | full address strings |
+| `conn:remote_ip()`, `conn:remote_port()`, `conn:local_port()` | address parts |
+| `conn:sni()` | the TLS Server Name Indication, or nil |
+| `conn:tls()` | a table `{version=, cipher=}`, or nil |
+
+Reads are capped at 1 MiB per call. `conn:get`, `set`, `has` and `delete` act on
+the per-connection store.
+
+## UDP: the peer argument
+
+`handle_packet` receives `peer`, a table with `peer.addr`, `peer.ip` and
+`peer.port`.
+
+## State stores
+
+A budgeted key/value store with `get`, `set`, `has` and `delete`, in three
+scopes.
+
+- `conn` holds values for one connection.
+- `handler` is shared by every connection of one handler.
+- `global` is shared by every listener in the process.
+
+Keys are limited to 4 KiB and values to 1 MiB, with a 64 MiB total budget. `set`
+returns false and an error message when a limit is reached.
+
+## Logging and comments
+
+```lua
+log:info("got a line") -- log:warn and log:error also exist
+log:info("from", conn:local()) -- several arguments join with a space
+capture:comment("flagged") -- annotates the packet just read or written
+```
+
+`print` writes at info level, the same as `log:info`. A comment written before
+any packet exists attaches to the next packet.
+
+## The sandbox
+
+Only the `base` (pruned), `string`, `table` and `math` libraries are available.
+The filesystem (`io`, `os`, `dofile`, `loadfile`, `require`, `load`,
+`loadstring`) and reflection (`debug`, `coroutine`, `rawget`, `getfenv`) are
+removed. A handler cannot read files, run code it did not define, or reach the
+host.
+
+## A complete UDP handler
+
+```lua
+function handle_packet(data, peer)
+ log:info("packet from", peer.ip, peer.port)
+ if data:find("who") then
+ return "gonetsim"
+ end
+ return nil
+end
+```
+
+## Reserved namespaces
+
+`http.respond` and `dns.answer` are reserved for future high-level HTTP and DNS
+simulation. They currently raise an error. Protocol simulation today is written
+directly against `conn`, as in `handlers/irc.lua`.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..ab353e9
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,47 @@
+# Quick start
+
+GoNetSim runs a simulated network service that malware can phone home to. You
+point a handler at a TCP or UDP port. Every connection is logged to the terminal
+and written to a pcapng file that opens in Wireshark.
+
+## Run a built-in handler
+
+The echo and sink handlers need no script file.
+
+```sh
+gonetsim echo@:7777
+gonetsim sink@:9999/udp
+```
+
+## Run a Lua handler
+
+Handlers are plain Lua files. The `handlers/` directory in the repository holds
+example scripts.
+
+```sh
+gonetsim handlers/irc.lua@:6667
+```
+
+## Run several listeners at once
+
+Each positional argument is one listener. All of them share a single capture
+file.
+
+```sh
+gonetsim handlers/irc.lua@:6667 sink@:9999/udp --pcap case.pcapng
+```
+
+## Read the capture
+
+Stop the simulator with Ctrl-C. The terminal prints the file path and the packet
+count. Open the `.pcapng` file in Wireshark. Comments a handler writes with
+`capture:comment()` appear on their packets. If a listener uses `--tls`, add
+`--tls-keylog` so the capture is decryptable.
+
+## More documentation
+
+```sh
+gonetsim docs flags
+gonetsim docs capture
+gonetsim docs lua-api
+```
diff --git a/docs/tls.md b/docs/tls.md
new file mode 100644
index 0000000..3b0ee39
--- /dev/null
+++ b/docs/tls.md
@@ -0,0 +1,37 @@
+# TLS
+
+GoNetSim needs no network. The binary embeds its documentation and can generate
+its own certificates, so a static binary plus your handler files is everything an
+isolated lab needs.
+
+Samples will attempt to hamper dynamic analysis by checking how "real" a server
+seems, and a common way to check this through TLS is by validating the
+server certificate.
+
+A persistent certificate lets you trust the CA on the analyst host once and have
+every later run present an identical server.
+
+```sh
+gonetsim tls
+gonetsim handlers/irc.lua@:6667 --tls \
+ --tls-cert ./tls/gonetsim-cert.pem --tls-key ./tls/gonetsim-key.pem
+```
+
+The first command writes `gonetsim-cert.pem`, `gonetsim-key.pem` and
+`gonetsim-ca.pem` under `./tls/`. Trust `gonetsim-ca.pem` once; the certificate
+is signed by it.
+
+- `gonetsim tls --force` regenerates the pair.
+- `gonetsim tls --verify-only` checks the existing files without changing them.
+
+## Reading TLS traffic
+
+TLS is captured as ciphertext, exactly as it would appear on a real network,
+while the handler still sees plaintext. To read the traffic in Wireshark, add
+`--tls-keylog ./keys.log` and set the TLS (Pre)-Master-Secret log filename to
+that file. See `gonetsim docs capture`.
+
+## Moving between machines
+
+Copy the static binary, the `handlers/` directory, and `./tls/`. On Windows,
+`gonetsim.exe` behaves the same, including capture and key-log file paths.
diff --git a/examples/gonetsim-listeners.toml b/examples/gonetsim-listeners.toml
deleted file mode 100644
index 10d5f10..0000000
--- a/examples/gonetsim-listeners.toml
+++ /dev/null
@@ -1,69 +0,0 @@
-# Example GoNetSim configuration demonstrating custom listeners
-#
-# This config disables the built-in services and runs four custom listeners:
-# - irc : fake IRC server (lua:handlers/irc.lua)
-# - ftp : fake FTP server (lua:handlers/ftp.lua)
-# - echo : TCP echo service (builtin:echo)
-# - sink : UDP discard sink (builtin:sink)
-# A fifth example (smtp, lua:handlers/smtp.lua) is commented out below
-# uncomment to try the AUTH/state demo on :2525
-#
-# Run from the repository root with:
-# gonetsim --config examples/gonetsim-listeners.toml
-#
-# Individual targets can also be run on demand, regardless of their enabled
-# flag:
-# gonetsim run irc --config examples/gonetsim-listeners.toml
-#
-# Or without any configuration at all:
-# gonetsim run echo@:7777
-# gonetsim run lua:examples/handlers/irc.lua@:6667
-#
-# Lua script paths are resolved relative to this config file's directory.
-
-[general]
-shutdown_timeout = "2s"
-
-[dns]
-enabled = false
-
-[http]
-enabled = false
-
-[https]
-enabled = false
-
-[logging]
-format = "text"
-level = "info"
-
-[[listeners]]
-name = "irc"
-type = "tcp"
-listen = ":6667"
-handler = "lua:handlers/irc.lua"
-
-[[listeners]]
-name = "ftp"
-type = "tcp"
-listen = ":2121"
-handler = "lua:handlers/ftp.lua"
-read_timeout = "60s"
-
-[[listeners]]
-name = "echo"
-type = "tcp"
-listen = ":7777"
-handler = "builtin:echo"
-
-[[listeners]]
-name = "sink"
-type = "udp"
-listen = ":9999"
-handler = "builtin:sink"
-
-# [[listeners]]
-# name = "smtp"
-# type = "tcp"
-# listen = ":2525"
-# handler = "lua:handlers/smtp.lua"
diff --git a/examples/handlers/ftp.lua b/examples/handlers/ftp.lua
deleted file mode 100644
index 665b329..0000000
--- a/examples/handlers/ftp.lua
+++ /dev/null
@@ -1,64 +0,0 @@
--- Minimal fake FTP server for GoNetSim custom listeners
---
--- Config:
--- [[listeners]]
--- name = "ftp"
--- type = "tcp"
--- listen = ":21"
--- handler = "lua:handlers/ftp.lua"
---
--- Try it:
--- curl ftp://127.0.0.1:21/ --user guest:guest
-
-function handle(conn)
- local user = nil
- local logged_in = false
-
- conn:write("220 GoNetSim FTP Server\r\n")
-
- while true do
- local line = conn:read_line()
- if not line then break end
- line = line:gsub("%s+$", "")
-
- if line ~= "" then
- capture:comment("ftp: " .. line)
-
- local cmd = line:match("^(%S+)")
- local arg = line:match("^%S+%s+(.+)$")
-
- if cmd == "USER" then
- user = arg
- conn:write("331 Password required\r\n")
- elseif cmd == "PASS" then
- if user then
- logged_in = true
- conn:write("230 Login successful\r\n")
- else
- conn:write("503 Login with USER first\r\n")
- end
- elseif cmd == "SYST" then
- conn:write("215 UNIX Type: L8\r\n")
- elseif cmd == "PWD" then
- conn:write("257 \"/\" is the current directory\r\n")
- elseif cmd == "TYPE" then
- conn:write("200 Type set\r\n")
- elseif cmd == "LIST" then
- conn:write("150 Here comes the directory listing\r\n")
- conn:write("total 0\r\n")
- conn:write("226 Transfer complete\r\n")
- elseif cmd == "RETR" then
- if logged_in then
- conn:write("550 File not found\r\n")
- else
- conn:write("530 Please login with USER and PASS\r\n")
- end
- elseif cmd == "QUIT" then
- conn:write("221 Goodbye\r\n")
- break
- else
- conn:write("500 Command not recognized\r\n")
- end
- end
- end
-end
diff --git a/go.mod b/go.mod
index e5e59d1..d8ab839 100644
--- a/go.mod
+++ b/go.mod
@@ -3,29 +3,44 @@ module github.com/lachlanharrisdev/gonetsim
go 1.26.1
require (
- github.com/fatih/color v1.19.0
+ charm.land/lipgloss/v2 v2.0.5
+ charm.land/log/v2 v2.0.1
+ github.com/charmbracelet/x/term v0.2.2
github.com/google/gopacket v1.1.19
- github.com/knadh/koanf/parsers/toml/v2 v2.2.2
- github.com/knadh/koanf/providers/confmap v1.0.1
- github.com/knadh/koanf/providers/file v1.2.1
- github.com/knadh/koanf/v2 v2.3.6
- github.com/lmittmann/tint v1.2.0
- github.com/mattn/go-colorable v0.1.15
- github.com/mattn/go-isatty v0.0.24
- github.com/miekg/dns v1.1.73
+ github.com/lachlanharrisdev/go-std-markdown v0.2.1
github.com/spf13/cobra v1.10.2
github.com/yuin/gopher-lua v1.1.2
)
require (
- github.com/fsnotify/fsnotify v1.10.1 // indirect
- github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/MichaelMure/go-term-text v0.3.1 // indirect
+ github.com/alecthomas/chroma v0.10.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
+ github.com/charmbracelet/x/ansi v0.11.7 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/disintegration/imaging v1.6.2 // indirect
+ github.com/dlclark/regexp2 v1.12.0 // indirect
+ github.com/eliukblau/pixterm v1.3.3 // indirect
+ github.com/fatih/color v1.19.0 // indirect
+ github.com/go-logfmt/logfmt v0.6.1 // indirect
+ github.com/gomarkdown/markdown v0.0.0-20260907091611-16791ada75cf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/knadh/koanf/maps v0.1.3 // indirect
- github.com/mitchellh/copystructure v1.2.0 // indirect
- github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/pelletier/go-toml/v2 v2.4.3 // indirect
+ github.com/kyokomi/emoji/v2 v2.2.14 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
+ github.com/mattn/go-colorable v0.1.15 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/mattn/go-runewidth v0.0.30 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
+ golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.58.0 // indirect
- golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
)
diff --git a/go.sum b/go.sum
index d469aad..75bba45 100644
--- a/go.sum
+++ b/go.sum
@@ -1,55 +1,92 @@
+charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
+charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
+charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0=
+charm.land/log/v2 v2.0.1/go.mod h1:VXZNB8zoPPEIcvd7bRi2fpQpGCWweCSfWOwczA9dibs=
+github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
+github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
+github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/eliukblau/pixterm v1.3.3 h1:kgEgMpffLlmiBNRiskDb6lLkrmK+XKOmGXqmnRL4EN4=
+github.com/eliukblau/pixterm v1.3.3/go.mod h1:xoQmNwx1i4psfnF+fM5YlEs+5Wfs0yMBFlHSAAtg+dY=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
-github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
-github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
-github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
-github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
+github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
+github.com/gomarkdown/markdown v0.0.0-20260907091611-16791ada75cf h1:GuSWkvxM+SaV24m/nZumjBkEE036ViQzmZFPlX15YhA=
+github.com/gomarkdown/markdown v0.0.0-20260907091611-16791ada75cf/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
-github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
-github.com/knadh/koanf/parsers/toml/v2 v2.2.2 h1:wbGxbgzNMsdEpnybeSPpI8sZixARaEr4+sLW+j+/hLM=
-github.com/knadh/koanf/parsers/toml/v2 v2.2.2/go.mod h1:JMyUfTKxpuou5VgLw/RXvKXMixIKEwJXALZon+pt0pg=
-github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A=
-github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
-github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
-github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
-github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
-github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
-github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY=
-github.com/lmittmann/tint v1.2.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
+github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
+github.com/lachlanharrisdev/go-std-markdown v0.2.1 h1:GDGRVRQdi8RTFQ6XrcQap3YiOTU4hnrXjToK8IG+Pf8=
+github.com/lachlanharrisdev/go-std-markdown v0.2.1/go.mod h1:kZJ3H6XhTMhlk02E9ugvB9asHlDJBVAIqsAIM9AvgbQ=
+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
-github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
-github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
-github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
-github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
-github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
-github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
+golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -57,15 +94,16 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/handlers/irc.lua b/handlers/irc.lua
similarity index 86%
rename from examples/handlers/irc.lua
rename to handlers/irc.lua
index c3ec907..239fe88 100644
--- a/examples/handlers/irc.lua
+++ b/handlers/irc.lua
@@ -1,13 +1,8 @@
--- Minimal fake IRC server for GoNetSim custom listeners
+-- Minimal fake IRC server
--
--- Config:
--- [[listeners]]
--- name = "irc"
--- type = "tcp"
--- listen = ":6667"
--- handler = "lua:handlers/irc.lua"
---
--- Try it:
+-- Run:
+-- gonetsim handlers/irc.lua@:6667
+-- Test:
-- irssi -c 127.0.0.1 -p 6667
function handle(conn)
@@ -43,4 +38,4 @@ function handle(conn)
end
end
end
-end
+end
\ No newline at end of file
diff --git a/examples/handlers/smtp.lua b/handlers/smtp.lua
similarity index 80%
rename from examples/handlers/smtp.lua
rename to handlers/smtp.lua
index 3d34797..776a5a7 100644
--- a/examples/handlers/smtp.lua
+++ b/handlers/smtp.lua
@@ -1,28 +1,25 @@
--- Fake SMTP server for GoNetSim custom listeners. Accepts mail and captures
--- everything: envelope addresses, the message body and decoded AUTH
--- credentials. Replies are deferred by DELAY_MS to imitate a slow server
+-- Minimal fake SMTP server
--
--- Limitations:
--- no STARTTLS (use tls = true for implicit-TLS 465)
--- minimal capability list
+-- Run:
+-- gonetsim handlers/smtp.lua@:25
+-- gonetsim handlers/smtp.lua@:465 --tls # IMPLICIT TLS, there's no STARTTLS
--
--- Handler state spans connections: a mail counter and per-sender memory,
--- demonstrating handler:get/set/has.
---
--- Config:
--- [[listeners]]
--- name = "smtp"
--- type = "tcp"
--- listen = ":25"
--- handler = "lua:handlers/smtp.lua"
--- tls = false
+-- Test:
+-- nc -C 127.0.0.1 25
+-- nc -C 127.0.0.1 465
local DELAY_MS = 0
-local DOMAIN = "gonetsim.invalid"
+local DOMAIN = "gonetsim.local"
local B64_USERNAME_PROMPT = "VXNlcm5hbWU6" -- base64("Username:")
local B64_PASSWORD_PROMPT = "UGFzc3dvcmQ6" -- base64("Password:")
+----------------------------------------
+--
+-- BASE64 helpers
+--
+----------------------------------------
+
local b64index = {}
do
local chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
@@ -49,13 +46,22 @@ local function base64decode(data)
return table.concat(out)
end
+----------------------------------------
+--
+-- Utils
+--
+----------------------------------------
+
+-- envelopeAddr reports whether arg contains an email address contained in
+-- square [] brackets
local function envelopeAddr(arg)
return arg:match("<([^>]*)>") or arg
end
local NUL = string.char(0)
--- splits "a\0b\0c" into a table of fields
+-- splitNul splits "a\0b\0c" into a table of fields and returns the resulting
+-- table as an array
local function splitNul(s)
local parts, start = {}, 1
local i = s:find(NUL, 1, true)
@@ -68,6 +74,14 @@ local function splitNul(s)
return parts
end
+----------------------------------------
+--
+-- SMTP Functions
+--
+----------------------------------------
+
+-- doAuth recieves a connection, checks for an auth mechanism and performs
+-- auth out of the supported mechanisms (PLAIN, LOGIN)
local function doAuth(conn, arg)
local mech = arg:match("^(%S+)")
local payload
@@ -105,6 +119,7 @@ local function doAuth(conn, arg)
conn:write("235 2.7.0 Authentication successful\r\n")
end
+-- handle is the entrypoint for each connection and coordinates the logic
function handle(conn)
local sender, recipients = nil, {}
@@ -149,8 +164,8 @@ function handle(conn)
conn:write("354 End data with .\r\n")
local msg = conn:read_until("\r\n.\r\n")
if not msg then break end
- msg = msg:gsub("\r\n%.\r\n$", "\r\n") -- strip the terminator
- msg = msg:gsub("\r\n%.%.", "\r\n.") -- un-dot-stuff
+ msg = msg:gsub("\r\n%.\r\n$", "\r\n")
+ msg = msg:gsub("\r\n%.%.", "\r\n.")
capture:comment("message: " .. msg)
local mails = tonumber(handler:get("mails")) or 0
mails = mails + 1
@@ -177,4 +192,4 @@ function handle(conn)
conn:write("500 5.5.2 Error: command not recognized\r\n")
end
end
-end
+end
\ No newline at end of file
diff --git a/internal/app/rungroup.go b/internal/app/rungroup.go
new file mode 100644
index 0000000..a3a2645
--- /dev/null
+++ b/internal/app/rungroup.go
@@ -0,0 +1,64 @@
+// Package app runs a set of blocking server functions together: it starts them
+// all, cancels the rest when one fails or the parent context ends, and waits
+// for every goroutine to finish before returning.
+package app
+
+import (
+ "context"
+ "errors"
+ "sync"
+)
+
+// RunAll runs fns concurrently until every fn returns, the first error occurs
+// (the rest are cancelled), or ctx is done. The first non-cancellation error
+// is returned.
+func RunAll(ctx context.Context, fns ...func(context.Context) error) error {
+ if len(fns) == 0 {
+ return nil
+ }
+
+ runCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Buffered so each goroutine exits without waiting on the main select.
+ errc := make(chan error, len(fns))
+ var wg sync.WaitGroup
+ for _, fn := range fns {
+ wg.Add(1)
+ go func(f func(context.Context) error) {
+ defer wg.Done()
+ errc <- f(runCtx)
+ }(fn)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ var firstErr error
+ for {
+ select {
+ case <-ctx.Done():
+ cancel()
+ <-done
+ return nil
+ case err := <-errc:
+ if err != nil && !errors.Is(err, context.Canceled) && firstErr == nil {
+ firstErr = err
+ cancel()
+ }
+ case <-done:
+ // All goroutines have finished sending; the channel is full to
+ // capacity. Drain whatever the main loop hasn't consumed yet.
+ close(errc)
+ for err := range errc {
+ if err != nil && !errors.Is(err, context.Canceled) && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+ }
+ }
+}
diff --git a/internal/capture/inspect.go b/internal/capture/inspect.go
deleted file mode 100644
index a2d55d3..0000000
--- a/internal/capture/inspect.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package capture
-
-import (
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "os"
- "strings"
- "time"
-
- "github.com/google/gopacket/layers"
- "github.com/google/gopacket/pcapgo"
-)
-
-type FileInfo struct {
- LinkType layers.LinkType
- Packets uint64
- First time.Time
- Last time.Time
- CreatedBy string
- Interfaces []string
-}
-
-// true if b holds the magic bytes of a legacy pcap
-// for either byte order and either timestamp resolution
-func isLegacyMagic(b []byte) bool {
- return b[0] == 0xa1 && b[1] == 0xb2 && b[2] == 0xc3 && b[3] == 0xd4 ||
- b[0] == 0xa1 && b[1] == 0xb2 && b[2] == 0x3c && b[3] == 0x4d ||
- b[3] == 0xa1 && b[2] == 0xb2 && b[1] == 0xc3 && b[0] == 0xd4 ||
- b[3] == 0xa1 && b[2] == 0xb2 && b[1] == 0x3c && b[0] == 0x4d
-}
-
-func isHeaderOnly(f *os.File) (bool, error) {
- st, err := f.Stat()
- if err != nil {
- return false, err
- }
- var hdr [8]byte
- if _, err := f.ReadAt(hdr[:], 0); err != nil {
- return false, err
- }
- blockType := binary.LittleEndian.Uint32(hdr[0:4])
- blockLen := int64(binary.LittleEndian.Uint32(hdr[4:8]))
- return blockType == 0x0A0D0D0A && blockLen == st.Size(), nil
-}
-
-func Inspect(path string) (FileInfo, error) {
- f, err := os.Open(path)
- if err != nil {
- return FileInfo{}, fmt.Errorf("open %q: %w", path, err)
- }
- defer func() { _ = f.Close() }()
-
- var magic [4]byte
- if _, err := io.ReadFull(f, magic[:]); err != nil {
- return FileInfo{}, fmt.Errorf("%q is not a pcapng file: %w", path, err)
- }
- if isLegacyMagic(magic[:]) {
- return FileInfo{}, fmt.Errorf("%q is a legacy pcap file; pcapng is the only supported format", path)
- }
- if magic != [4]byte{0x0a, 0x0d, 0x0d, 0x0a} {
- return FileInfo{}, fmt.Errorf("%q is not a pcapng file", path)
- }
-
- if _, err := f.Seek(0, io.SeekStart); err != nil {
- return FileInfo{}, fmt.Errorf("seek %q: %w", path, err)
- }
- nr, err := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions)
- if err != nil {
- if empty, serr := isHeaderOnly(f); serr == nil && empty {
- return FileInfo{LinkType: layers.LinkTypeEthernet}, nil
- }
- return FileInfo{}, fmt.Errorf("read pcapng %q: %w", path, err)
- }
-
- info := FileInfo{LinkType: nr.LinkType(), CreatedBy: nr.SectionInfo().Application}
- for {
- _, ci, err := nr.ReadPacketData()
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil {
- return FileInfo{}, fmt.Errorf("read packet %d from %q: %w", info.Packets+1, path, err)
- }
- if info.Packets == 0 {
- info.First = ci.Timestamp
- }
- info.Last = ci.Timestamp
- info.Packets++
- }
- for i := 0; i < nr.NInterfaces(); i++ {
- iface, err := nr.Interface(i)
- if err != nil {
- break
- }
- info.Interfaces = append(info.Interfaces, strings.TrimRight(iface.Name, "\x00"))
- }
- return info, nil
-}
diff --git a/internal/capture/session_test.go b/internal/capture/session_test.go
deleted file mode 100644
index 7c1e212..0000000
--- a/internal/capture/session_test.go
+++ /dev/null
@@ -1,349 +0,0 @@
-////----------------------------------------------------------------------------
-// NOTICE: to save development time, test files (including this) have been
-// generated with LLMs. The author(s) do not claim credit for these tests
-// and exist purely for maximising code quality and reliability
-//
-// For more information please see `/.github/AI_USAGE.md`
-//----------------------------------------------------------------------------//
-
-package capture
-
-import (
- "bytes"
- "io"
- "net/netip"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/google/gopacket"
- "github.com/google/gopacket/layers"
- "github.com/google/gopacket/pcapgo"
-)
-
-type frame struct {
- src, dst netip.AddrPort
- syn, ack, fin bool
- seq, ackNum uint32
- payload string
-}
-
-func testRun(t *testing.T) (*Run, string) {
- t.Helper()
- path := filepath.Join(t.TempDir(), "run.pcapng")
- run, err := NewRun(path)
- if err != nil {
- t.Fatalf("NewRun: %v", err)
- }
- t.Cleanup(func() { _ = run.Close() })
- if _, err := run.NewInterface("test"); err != nil {
- t.Fatalf("NewInterface: %v", err)
- }
- return run, path
-}
-
-func testSession(t *testing.T, run *Run, network string, local, remote netip.AddrPort) *Session {
- t.Helper()
- ses, err := run.NewSession(network, local, remote, 0)
- if err != nil {
- t.Fatalf("NewSession: %v", err)
- }
- return ses
-}
-
-func readFrames(t *testing.T, path string) []gopacket.Packet {
- t.Helper()
- f, err := os.Open(path)
- if err != nil {
- t.Fatalf("Open: %v", err)
- }
- defer func() { _ = f.Close() }()
- r, err := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions)
- if err != nil {
- t.Fatalf("NewNgReader: %v", err)
- }
- if r.LinkType() != layers.LinkTypeEthernet {
- t.Fatalf("LinkType = %v, want Ethernet", r.LinkType())
- }
- var out []gopacket.Packet
- for {
- data, _, err := r.ReadPacketData()
- if err == io.EOF {
- break
- }
- if err != nil {
- t.Fatalf("ReadPacketData: %v", err)
- }
- p := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default)
- if p.ErrorLayer() != nil {
- t.Fatalf("packet failed to decode: %v", p.ErrorLayer().Error())
- }
- out = append(out, p)
- }
- return out
-}
-
-func checkTCP(t *testing.T, p gopacket.Packet, want frame) {
- t.Helper()
- tcp, ok := p.Layer(layers.LayerTypeTCP).(*layers.TCP)
- if !ok {
- t.Fatalf("packet is not TCP: %v", p.Layers())
- }
- if tcp.SrcPort != layers.TCPPort(want.src.Port()) || tcp.DstPort != layers.TCPPort(want.dst.Port()) {
- t.Errorf("ports = %s:%s, want %d:%d", tcp.SrcPort, tcp.DstPort, want.src.Port(), want.dst.Port())
- }
- if tcp.SYN != want.syn || tcp.ACK != want.ack || tcp.FIN != want.fin {
- t.Errorf("flags SYN=%v ACK=%v FIN=%v, want SYN=%v ACK=%v FIN=%v", tcp.SYN, tcp.ACK, tcp.FIN, want.syn, want.ack, want.fin)
- }
- if tcp.Seq != want.seq || tcp.Ack != want.ackNum {
- t.Errorf("seq/ack = %d/%d, want %d/%d", tcp.Seq, tcp.Ack, want.seq, want.ackNum)
- }
- if string(tcp.Payload) != want.payload {
- t.Errorf("payload = %q, want %q", tcp.Payload, want.payload)
- }
-}
-
-func TestSessionTCP(t *testing.T) {
- local := netip.MustParseAddrPort("127.0.0.1:8080")
- remote := netip.MustParseAddrPort("10.0.0.5:40000")
- run, path := testRun(t)
-
- ses := testSession(t, run, "tcp", local, remote)
- if err := ses.Write([]byte("hello"), true); err != nil {
- t.Fatalf("Write client: %v", err)
- }
- if err := ses.Write([]byte("world"), false); err != nil {
- t.Fatalf("Write server: %v", err)
- }
- if err := ses.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- pkts := readFrames(t, path)
- want := []frame{
- {remote, local, true, false, false, 0, 0, ""},
- {local, remote, true, true, false, 0, 1, ""},
- {remote, local, false, true, false, 1, 1, "hello"},
- {local, remote, false, true, false, 1, 6, "world"},
- {remote, local, false, true, true, 6, 6, ""},
- {local, remote, false, true, true, 6, 7, ""},
- }
- if len(pkts) != len(want) {
- t.Fatalf("got %d packets, want %d (SYN, SYN-ACK, 2 data, 2 FIN)", len(pkts), len(want))
- }
- for i, w := range want {
- checkTCP(t, pkts[i], w)
- }
-}
-
-func TestSessionTCPIPv6(t *testing.T) {
- local := netip.MustParseAddrPort("[::1]:8080")
- remote := netip.MustParseAddrPort("[2001:db8::5]:40000")
- run, path := testRun(t)
-
- ses := testSession(t, run, "tcp", local, remote)
- if err := ses.Write([]byte("ping"), true); err != nil {
- t.Fatalf("Write: %v", err)
- }
- if err := ses.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- pkts := readFrames(t, path)
- if len(pkts) != 5 {
- t.Fatalf("got %d packets, want 5", len(pkts))
- }
- if pkts[0].Layer(layers.LayerTypeIPv6) == nil {
- t.Fatalf("expected IPv6 frames, got %v", pkts[0].Layers())
- }
-}
-
-func TestSessionUDP(t *testing.T) {
- local := netip.MustParseAddrPort("127.0.0.1:12345")
- remote := netip.MustParseAddrPort("10.0.0.5:5000")
- run, path := testRun(t)
-
- ses := testSession(t, run, "udp", local, remote)
- if err := ses.Write([]byte("query"), true); err != nil {
- t.Fatalf("Write client: %v", err)
- }
- if err := ses.Write([]byte("answer"), false); err != nil {
- t.Fatalf("Write server: %v", err)
- }
- if err := ses.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- pkts := readFrames(t, path)
- if len(pkts) != 2 {
- t.Fatalf("got %d packets, want 2", len(pkts))
- }
- for i, want := range []struct {
- src, dst netip.AddrPort
- payload string
- }{
- {remote, local, "query"},
- {local, remote, "answer"},
- } {
- udp, ok := pkts[i].Layer(layers.LayerTypeUDP).(*layers.UDP)
- if !ok {
- t.Fatalf("packet %d is not UDP", i)
- }
- if udp.SrcPort != layers.UDPPort(want.src.Port()) || udp.DstPort != layers.UDPPort(want.dst.Port()) {
- t.Errorf("packet %d ports = %s:%s, want %d:%d", i, udp.SrcPort, udp.DstPort, want.src.Port(), want.dst.Port())
- }
- if string(udp.Payload) != want.payload {
- t.Errorf("packet %d payload = %q, want %q", i, udp.Payload, want.payload)
- }
- }
-}
-
-func TestSessionComment(t *testing.T) {
- local := netip.MustParseAddrPort("127.0.0.1:8080")
- remote := netip.MustParseAddrPort("10.0.0.5:40000")
- run, path := testRun(t)
-
- ses := testSession(t, run, "tcp", local, remote)
- ses.Comment("he-lo")
- if err := ses.Write([]byte("hello"), true); err != nil {
- t.Fatalf("Write: %v", err)
- }
- if err := ses.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- raw, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("ReadFile: %v", err)
- }
- // the comment attaches to the next frame (here SYN); the file must still
- // parse cleanly as pcapng
- if !bytes.Contains(raw, []byte("he-lo")) {
- t.Fatal("comment text not found in pcapng bytes")
- }
- if pkts := readFrames(t, path); len(pkts) != 5 {
- t.Fatalf("got %d packets, want 5", len(pkts))
- }
-}
-
-func TestRun(t *testing.T) {
- t.Run("one file holds many flows", func(t *testing.T) {
- run, path := testRun(t)
- local := netip.MustParseAddrPort("127.0.0.1:53")
- remote := netip.MustParseAddrPort("203.0.113.10:43210")
-
- udp := testSession(t, run, "udp", local, remote)
- if err := udp.Write([]byte("query"), true); err != nil {
- t.Fatalf("Write: %v", err)
- }
- if err := udp.Write([]byte("answer"), false); err != nil {
- t.Fatalf("Write: %v", err)
- }
- if err := udp.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- tcp := testSession(t, run, "tcp", local, remote)
- if err := tcp.Write([]byte("hello"), true); err != nil {
- t.Fatalf("Write: %v", err)
- }
- if err := tcp.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- info, err := Inspect(path)
- if err != nil {
- t.Fatalf("Inspect: %v", err)
- }
- if info.LinkType != layers.LinkTypeEthernet || info.Packets != 7 {
- t.Fatalf("unexpected inspect result %+v", info)
- }
- if len(info.Interfaces) != 1 || info.Interfaces[0] != "test" {
- t.Fatalf("unexpected interfaces %+v", info.Interfaces)
- }
- if info.CreatedBy != "gonetsim" {
- t.Fatalf("unexpected created-by %q", info.CreatedBy)
- }
- if packets, _, _ := run.Stats(); packets != 7 {
- t.Fatalf("Stats packets = %d, want 7", packets)
- }
- })
-
- t.Run("run path resolution", func(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_DATA_HOME", dir)
- got, err := RunPath("")
- if err != nil {
- t.Fatalf("RunPath: %v", err)
- }
- wantDir := filepath.Join(dir, "gonetsim", "runs")
- if filepath.Dir(got) != wantDir || !strings.HasSuffix(got, ".pcapng") {
- t.Fatalf("RunPath = %q, want dir %q with .pcapng suffix", got, wantDir)
- }
-
- explicit := filepath.Join(dir, "case", "run.pcapng")
- got, err = RunPath(explicit)
- if err != nil {
- t.Fatalf("RunPath explicit: %v", err)
- }
- if got != explicit {
- t.Fatalf("RunPath explicit = %q, want %q", got, explicit)
- }
- if st, err := os.Stat(filepath.Join(dir, "case")); err != nil || !st.IsDir() {
- t.Fatalf("expected parent dir to be created: %v", err)
- }
- })
-}
-
-func TestInspect(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "inspect", "manual.pcapng")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatalf("MkdirAll: %v", err)
- }
- f, err := os.Create(path)
- if err != nil {
- t.Fatalf("Create: %v", err)
- }
- w, err := pcapgo.NewNgWriter(f, layers.LinkTypeEthernet)
- if err != nil {
- t.Fatalf("NewNgWriter: %v", err)
- }
- ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
- for i, p := range [][]byte{{0xde, 0xad}, {0xca, 0xfe}} {
- ci := gopacket.CaptureInfo{Timestamp: ts.Add(time.Duration(i) * time.Second), CaptureLength: len(p), Length: len(p)}
- if err := w.WritePacket(ci, p); err != nil {
- t.Fatalf("WritePacket: %v", err)
- }
- }
- if err := w.Flush(); err != nil {
- t.Fatalf("Flush: %v", err)
- }
- if err := f.Close(); err != nil {
- t.Fatalf("Close: %v", err)
- }
-
- info, err := Inspect(path)
- if err != nil {
- t.Fatalf("Inspect: %v", err)
- }
- if info.LinkType != layers.LinkTypeEthernet {
- t.Fatalf("LinkType = %v, want Ethernet", info.LinkType)
- }
- if info.Packets != 2 {
- t.Fatalf("Packets = %d, want 2", info.Packets)
- }
- if !info.First.Equal(ts) || !info.Last.Equal(ts.Add(time.Second)) {
- t.Fatalf("First/Last timestamps = %v/%v, want %v/%v", info.First, info.Last, ts, ts.Add(time.Second))
- }
-
- legacy := filepath.Join(dir, "legacy.pcap")
- if err := os.WriteFile(legacy, []byte{0xd4, 0xc3, 0xb2, 0xa1}, 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
- if _, err := Inspect(legacy); err == nil || !strings.Contains(err.Error(), "legacy pcap") {
- t.Fatalf("expected legacy pcap error, got %v", err)
- }
-}
diff --git a/internal/config/config.go b/internal/config/config.go
deleted file mode 100644
index bb3d80a..0000000
--- a/internal/config/config.go
+++ /dev/null
@@ -1,342 +0,0 @@
-package config
-
-import (
- _ "embed"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "time"
-
- "github.com/knadh/koanf/parsers/toml/v2"
- "github.com/knadh/koanf/providers/confmap"
- "github.com/knadh/koanf/providers/file"
- "github.com/knadh/koanf/v2"
-)
-
-const (
- systemConfigPath = "/etc/gonetsim/gonetsim.toml"
- localConfigPath = "./gonetsim.toml"
-)
-
-//go:embed default_config.toml
-var defaultConfigTOML []byte
-
-type Config struct {
- General GeneralConfig `koanf:"general"`
- DNS DNSConfig `koanf:"dns"`
- HTTP HTTPConfig `koanf:"http"`
- HTTPS HTTPSConfig `koanf:"https"`
- Logging LoggingConfig `koanf:"logging"`
- State StateConfig `koanf:"state"`
- Listeners []ListenerConfig `koanf:"listeners"`
-}
-
-// empty TotalLimit means the state package default
-type StateConfig struct {
- TotalLimit string `koanf:"total_limit"`
-}
-
-type GeneralConfig struct {
- ShutdownTimeout time.Duration `koanf:"shutdown_timeout"`
-}
-
-type ServiceBase struct {
- Enabled bool `koanf:"enabled"`
- Listen string `koanf:"listen"`
- Capture bool `koanf:"capture"`
-}
-
-type DNSConfig struct {
- ServiceBase `koanf:",squash"`
- Network string `koanf:"network"`
- IPv4 string `koanf:"ipv4"`
- IPv6 string `koanf:"ipv6"`
- Domain string `koanf:"domain"`
- TXT string `koanf:"txt"`
- TTL uint32 `koanf:"ttl"`
- Compress bool `koanf:"compress"`
-}
-
-type HTTPConfig struct {
- ServiceBase `koanf:",squash"`
- Status int `koanf:"status"`
- Mode string `koanf:"mode"`
- RootDir string `koanf:"root_dir"`
-}
-
-type HTTPSConfig struct {
- ServiceBase `koanf:",squash"`
- Status int `koanf:"status"`
- Mode string `koanf:"mode"`
- RootDir string `koanf:"root_dir"`
- Cert string `koanf:"cert"`
- Key string `koanf:"key"`
-}
-
-type LoggingConfig struct {
- LogFormat string `koanf:"format"`
- Level string `koanf:"level"`
-}
-
-// ListenerConfig is a single [[listeners]] entry; Enabled/Capture default
-// to true when unset.
-type ListenerConfig struct {
- Name string `koanf:"name"`
- Enabled *bool `koanf:"enabled"`
- Type string `koanf:"type"`
- Listen string `koanf:"listen"`
- Handler string `koanf:"handler"`
- ReadTimeout time.Duration `koanf:"read_timeout"`
- TLS bool `koanf:"tls"`
- TLSCert string `koanf:"tls_cert"`
- TLSKey string `koanf:"tls_key"`
- Capture *bool `koanf:"capture"`
-}
-
-func (c ListenerConfig) IsEnabled() bool {
- return c.Enabled == nil || *c.Enabled
-}
-
-func (c ListenerConfig) ShouldCapture() bool {
- return c.Capture == nil || *c.Capture
-}
-
-func Default() Config {
- return Config{
- General: GeneralConfig{ShutdownTimeout: 2 * time.Second},
- DNS: DNSConfig{
- ServiceBase: ServiceBase{Enabled: true, Listen: ":53", Capture: true},
- Network: "udp",
- IPv4: "auto",
- IPv6: "::1",
- Domain: "localhost",
- TXT: "TXT record response from GoNetSim",
- TTL: 60,
- Compress: false,
- },
- HTTP: HTTPConfig{
- ServiceBase: ServiceBase{Enabled: true, Listen: ":80", Capture: true},
- Status: 200,
- Mode: "fake",
- },
- HTTPS: HTTPSConfig{
- ServiceBase: ServiceBase{Enabled: true, Listen: ":443", Capture: true},
- Status: 200,
- Mode: "fake",
- },
- Logging: LoggingConfig{
- LogFormat: "text",
- Level: "info",
- },
- State: StateConfig{
- TotalLimit: "64MiB",
- },
- }
-}
-
-func (c Config) Validate() error {
- if c.General.ShutdownTimeout <= 0 {
- return errors.New("general.shutdown_timeout must be > 0")
- }
-
- // logging
- logFormat := strings.ToLower(strings.TrimSpace(c.Logging.LogFormat))
- switch logFormat {
- case "", "text", "json":
- // ok
- default:
- return fmt.Errorf("logging.format must be one of: text, json")
- }
- // default is "info" (see Default()); allow empty for backwards compat
- logLevel := strings.ToLower(strings.TrimSpace(c.Logging.Level))
- switch logLevel {
- case "", "debug", "info", "warn", "warning", "error":
- // ok
- default:
- return fmt.Errorf("logging.level must be one of: debug, info, warn, error")
- }
-
- if strings.TrimSpace(c.State.TotalLimit) != "" {
- if _, err := ParseSize(c.State.TotalLimit); err != nil {
- return fmt.Errorf("state.total_limit: %w", err)
- }
- }
-
- // deep listener validation happens in the listener package
- names := make(map[string]bool, len(c.Listeners))
- for _, l := range c.Listeners {
- if strings.TrimSpace(l.Name) == "" {
- return errors.New("each listener must have a name")
- }
- if names[l.Name] {
- return fmt.Errorf("listener name %q is used more than once", l.Name)
- }
- names[l.Name] = true
- }
-
- return nil
-}
-
-type LoadResult struct {
- Config Config
- Path string
- Created bool
-}
-
-func LoadOrCreate(configPath string) (LoadResult, error) {
- return LoadOrCreateWithOverrides(configPath, nil)
-}
-
-func LoadOrCreateWithOverrides(configPath string, overrides map[string]any) (LoadResult, error) {
- resolved, created, err := resolveAndCreate(configPath)
- if err != nil {
- return LoadResult{}, err
- }
-
- cfg, err := loadConfigFile(resolved, overrides)
- if err != nil {
- return LoadResult{}, err
- }
- return LoadResult{Config: cfg, Path: resolved, Created: created}, nil
-}
-
-// LoadOptional never creates a config file, unlike LoadOrCreate
-func LoadOptional(configPath string, overrides map[string]any) (LoadResult, error) {
- var path string
- if configPath != "" {
- if !fileExists(configPath) {
- return LoadResult{}, fmt.Errorf("config file %q not found", configPath)
- }
- path = configPath
- } else if existing, ok := firstExistingFile(defaultSearchPaths()); ok {
- path = existing
- } else {
- return LoadResult{Config: Default()}, nil
- }
-
- cfg, err := loadConfigFile(path, overrides)
- if err != nil {
- return LoadResult{}, err
- }
- return LoadResult{Config: cfg, Path: path}, nil
-}
-
-func loadConfigFile(path string, overrides map[string]any) (Config, error) {
- k := koanf.New(".")
-
- if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
- return Config{}, fmt.Errorf("load config %q: %w", path, err)
- }
-
- if len(overrides) > 0 {
- if err := k.Load(confmap.Provider(overrides, "."), nil); err != nil {
- return Config{}, fmt.Errorf("load overrides: %w", err)
- }
- }
-
- out := Default()
- if err := k.UnmarshalWithConf("", &out, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
- return Config{}, fmt.Errorf("unmarshal config: %w", err)
- }
- return out, nil
-}
-
-func resolveAndCreate(configPath string) (string, bool, error) {
- if configPath != "" {
- created, err := ensureConfigFile(configPath)
- return configPath, created, err
- }
-
- if existing, ok := firstExistingFile(defaultSearchPaths()); ok {
- return existing, false, nil
- }
-
- preferred := preferredDefaultPath()
- created, err := ensureConfigFile(preferred)
- return preferred, created, err
-}
-
-func defaultSearchPaths() []string {
- paths := make([]string, 0, 3)
-
- // in order of precedence
-
- // local config `./gonetsim.toml`
- paths = append(paths, localConfigPath)
-
- // user config `~/.config/gonetsim/config.toml`
- if d, err := os.UserConfigDir(); err == nil && d != "" {
- paths = append(paths, filepath.Join(d, "gonetsim", "config.toml"))
- }
-
- // system config `/etc/gonetsim/gonetsim.toml` (unix only)
- if runtime.GOOS != "windows" {
- paths = append(paths, systemConfigPath)
- }
-
- return paths
-}
-
-func preferredDefaultPath() string {
- if d, err := os.UserConfigDir(); err == nil && d != "" {
- return filepath.Join(d, "gonetsim", "config.toml")
- }
- return localConfigPath
-}
-
-func fileExists(path string) bool {
- st, err := os.Stat(path)
- return err == nil && !st.IsDir()
-}
-
-func firstExistingFile(paths []string) (string, bool) {
- for _, p := range paths {
- if fileExists(p) {
- return p, true
- }
- }
- return "", false
-}
-
-func ensureConfigFile(path string) (bool, error) {
- if fileExists(path) {
- return false, nil
- }
-
- dir := filepath.Dir(path)
- if dir != "." {
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return false, fmt.Errorf("create config dir %q: %w", dir, err)
- }
- }
-
- // write to a temp file in the same dir to make rename atomic
- tmpFile, err := os.CreateTemp(dir, ".gonetsim-*.toml")
- if err != nil {
- return false, fmt.Errorf("create temp config in %q: %w", dir, err)
- }
- tmpName := tmpFile.Name()
-
- if _, err := tmpFile.Write(defaultConfigTOML); err != nil {
- _ = tmpFile.Close()
- _ = os.Remove(tmpName)
- return false, fmt.Errorf("write default config %q: %w", tmpName, err)
- }
- if err := tmpFile.Close(); err != nil {
- _ = os.Remove(tmpName)
- return false, fmt.Errorf("close default config %q: %w", tmpName, err)
- }
- if err := os.Chmod(tmpName, 0o644); err != nil {
- _ = os.Remove(tmpName)
- return false, fmt.Errorf("chmod default config %q: %w", tmpName, err)
- }
- if err := os.Rename(tmpName, path); err != nil {
- _ = os.Remove(tmpName)
- return false, fmt.Errorf("install default config %q: %w", path, err)
- }
-
- return true, nil
-}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
deleted file mode 100644
index cf9aca8..0000000
--- a/internal/config/config_test.go
+++ /dev/null
@@ -1,216 +0,0 @@
-////----------------------------------------------------------------------------
-// NOTICE: to save development time, test files (including this) have been
-// generated with LLMs. The author(s) do not claim credit for these tests
-// and exist purely for maximising code quality and reliability
-//
-// For more information please see `/.github/AI_USAGE.md`
-//----------------------------------------------------------------------------//
-
-package config
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestLoadOrCreate_CreatesAndLoadsConfig(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "gonetsim.toml")
-
- res1, err := LoadOrCreate(path)
- if err != nil {
- t.Fatalf("LoadOrCreate: %v", err)
- }
- if !res1.Created {
- t.Fatalf("expected Created=true on first call")
- }
- if res1.Path != path {
- t.Fatalf("expected Path=%q, got %q", path, res1.Path)
- }
- if _, err := os.Stat(path); err != nil {
- t.Fatalf("expected config file to exist at %q: %v", path, err)
- }
- if err := res1.Config.Validate(); err != nil {
- t.Fatalf("expected loaded config to validate: %v", err)
- }
-
- res2, err := LoadOrCreate(path)
- if err != nil {
- t.Fatalf("LoadOrCreate (second): %v", err)
- }
- if res2.Created {
- t.Fatalf("expected Created=false on second call")
- }
- if res2.Path != path {
- t.Fatalf("expected Path=%q, got %q", path, res2.Path)
- }
-}
-
-func TestLoadOrCreateWithOverrides_AppliesOverrides(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "gonetsim.toml")
-
- res, err := LoadOrCreateWithOverrides(path, map[string]any{
- "http.mode": "real",
- "http.root_dir": dir,
- "dns.enabled": false,
- })
- if err != nil {
- t.Fatalf("LoadOrCreateWithOverrides: %v", err)
- }
-
- if res.Config.HTTP.Mode != "real" {
- t.Fatalf("expected http.mode=real, got %q", res.Config.HTTP.Mode)
- }
- if res.Config.HTTP.RootDir != dir {
- t.Fatalf("expected http.root_dir=%q, got %q", dir, res.Config.HTTP.RootDir)
- }
- if res.Config.DNS.Enabled {
- t.Fatalf("expected dns.enabled=false via override")
- }
-}
-
-func TestListenersConfig(t *testing.T) {
- t.Run("parse and defaults", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "gonetsim.toml")
- content := `
-[[listeners]]
-name = "irc"
-type = "tcp"
-listen = ":6667"
-handler = "lua:handlers/irc.lua"
-read_timeout = "45s"
-
-[[listeners]]
-name = "off"
-enabled = false
-type = "tcp"
-listen = ":1234"
-handler = "builtin:echo"
-
-[[listeners]]
-name = "sink"
-type = "udp"
-listen = ":9999"
-handler = "builtin:sink"
-capture = false
-`
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
-
- res, err := LoadOrCreate(path)
- if err != nil {
- t.Fatalf("LoadOrCreate: %v", err)
- }
- if err := res.Config.Validate(); err != nil {
- t.Fatalf("Validate: %v", err)
- }
- if len(res.Config.Listeners) != 3 {
- t.Fatalf("expected 3 listeners, got %d", len(res.Config.Listeners))
- }
-
- irc := res.Config.Listeners[0]
- if irc.Name != "irc" || irc.Type != "tcp" || irc.Listen != ":6667" || irc.Handler != "lua:handlers/irc.lua" {
- t.Fatalf("unexpected irc listener %+v", irc)
- }
- if !irc.IsEnabled() || !irc.ShouldCapture() {
- t.Fatalf("expected enabled and capture to default true")
- }
- if irc.ReadTimeout != 45*time.Second {
- t.Fatalf("expected read_timeout 45s, got %v", irc.ReadTimeout)
- }
- if res.Config.Listeners[1].IsEnabled() {
- t.Fatalf("expected enabled=false to be respected")
- }
- if res.Config.Listeners[2].ShouldCapture() {
- t.Fatalf("expected capture=false to be respected")
- }
- })
-
- t.Run("duplicate names rejected", func(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "gonetsim.toml")
- content := `
-[[listeners]]
-name = "dup"
-type = "tcp"
-listen = ":1"
-handler = "builtin:echo"
-
-[[listeners]]
-name = "dup"
-type = "tcp"
-listen = ":2"
-handler = "builtin:echo"
-`
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
-
- res, err := LoadOrCreate(path)
- if err != nil {
- t.Fatalf("LoadOrCreate: %v", err)
- }
- if err := res.Config.Validate(); err == nil {
- t.Fatalf("expected duplicate listener name error")
- }
- })
-}
-
-func TestFirstExistingFile_PrefersLocalThenUserThenSystem(t *testing.T) {
- if _, ok := firstExistingFile([]string{
- "/definitely/not/here/gonetsim.toml",
- "/also/not/here/gonetsim.toml",
- "/nor/here/gonetsim.toml",
- }); ok {
- t.Fatalf("expected no existing file")
- }
-
- dir := t.TempDir()
- a := filepath.Join(dir, "a.toml")
- b := filepath.Join(dir, "b.toml")
- if err := os.WriteFile(a, []byte("x"), 0o644); err != nil {
- t.Fatalf("WriteFile a: %v", err)
- }
- if err := os.WriteFile(b, []byte("x"), 0o644); err != nil {
- t.Fatalf("WriteFile b: %v", err)
- }
-
- got, ok := firstExistingFile([]string{a, b})
- if !ok {
- t.Fatalf("expected to find a file")
- }
- if got != a {
- t.Fatalf("expected first (highest precedence) file %q, got %q", a, got)
- }
-}
-
-func TestLegacyCaptureDirIgnored(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "gonetsim.toml")
- content := `
-[http]
-enabled = true
-listen = "127.0.0.1:0"
-capture = true
-capture_dir = "/tmp/should-be-ignored"
-`
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
-
- res, err := LoadOrCreate(path)
- if err != nil {
- t.Fatalf("LoadOrCreate: %v", err)
- }
- if err := res.Config.Validate(); err != nil {
- t.Fatalf("Validate: %v", err)
- }
- if !res.Config.HTTP.Capture {
- t.Fatalf("expected http.capture to survive")
- }
-}
diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml
deleted file mode 100644
index 8b8d0ea..0000000
--- a/internal/config/default_config.toml
+++ /dev/null
@@ -1,118 +0,0 @@
-# `gonetsim` configuration file
-#
-# This file is generated automatically the first time you run `gonetsim` if no
-# configuration file is found
-#
-# You can place it in one of the following locations, in order of precedence:
-# Linux:
-# - ./gonetsim.toml
-# - $XDG_CONFIG_HOME/gonetsim/config.toml (usually ~/.config/gonetsim/config.toml)
-# - /etc/gonetsim/gonetsim.toml
-# Windows:
-# - ./gonetsim.toml
-# - %APPDATA%\gonetsim\config.toml
-# Or specify an explicit path with:
-# gonetsim --config /path/to/gonetsim.toml
-
-[general]
-# How long to wait for servers to shut down after receiving SIGINT/SIGTERM.
-shutdown_timeout = "2s"
-
-[dns]
-enabled = true
-listen = ":53"
-# protocol(s) to listen on; "udp", "tcp" or "both"
-network = "udp"
-# Sinkhole IP for A responses. "auto" detects this machine's IPv4 address
-# (falling back to 127.0.0.1), or set an explicit IP.
-ipv4 = "auto"
-# Optional sinkhole IP for AAAA responses. Set empty string to disable.
-ipv6 = "::1"
-# Sinkhole domain for CNAME, MX & PTR responses
-domain = "localhost"
-# TXT record for TXT DNS responses
-txt = "TXT record response from GoNetSim"
-# TTL in seconds for DNS responses
-ttl = 60
-# Enable DNS message compression
-compress = false
-# Save every query/response flow to the run capture file (udp and/or tcp,
-# matching the configured network).
-capture = true
-
-[http]
-enabled = true
-listen = ":80"
-# Status code to return for all requests. 0 means default (200).
-status = 200
-# fake mode serves a fake file for any request; real mode serves files from the
-# directory specified in root_dir (required when mode = "real")
-mode = "fake"
-root_dir = ""
-# Save every connection to the run capture file.
-# Inspect it with `gonetsim pcap `.
-capture = true
-
-[https]
-enabled = true
-listen = ":443"
-# Status code to return for all requests. 0 means default (200).
-status = 200
-# fake mode serves a fake file for any request; real mode serves files from the
-# directory specified in root_dir (required when mode = "real")
-mode = "fake"
-root_dir = ""
-# Save every connection to the run capture file.
-# Note: captures hold TLS ciphertext, not plaintext.
-capture = true
-
-[logging]
-# Log output format: "text" or "json".
-format = "text"
-# Minimum log level: "debug", "info", "warn", "error".
-level = "info"
-
-[state]
-# Total budget for Lua state stores. When a script's set() would push
-# total state past this, it fails gracefully
-total_limit = "64MiB"
-
-# Custom listeners
-#
-# Generic TCP/UDP listeners for anything the services above don't cover.
-# Run all enabled listeners with `gonetsim`, or a single one with
-# `gonetsim run `. Listeners can also be started inline, without any
-# configuration, e.g. `gonetsim run echo@:7777` or `gonetsim run c2.lua@:8080`.
-#
-# Available handlers:
-# builtin:echo - echo all received data back to the client
-# builtin:sink - consume and discard all received data
-# lua: - serve with a Lua script (path relative to this config file)
-#
-# [[listeners]]
-# name = "irc"
-# enabled = true
-# # protocol; "tcp" (default) or "udp"
-# type = "tcp"
-# listen = ":6667"
-# handler = "lua:handlers/irc.lua"
-# # Idle timeout applied to reads and writes on each connection.
-# read_timeout = "30s"
-# # Wrap the listener in TLS (tcp only). With cert/key empty, a self-signed
-# # certificate is generated next to this config file.
-# tls = false
-# tls_cert = ""
-# tls_key = ""
-# # Write everything a client sends to the run capture file.
-# capture = true
-
-# SMTP-style mail sink, served by the example Lua handler:
-# https://github.com/lachlanharrisdev/gonetsim/tree/main/examples/handlers
-#
-# [[listeners]]
-# name = "smtp"
-# type = "tcp"
-# listen = ":25"
-# handler = "lua:handlers/smtp.lua"
-# # implicit TLS (the 465/SMTPS flavour); STARTTLS is not supported
-# tls = false
diff --git a/internal/config/size.go b/internal/config/size.go
deleted file mode 100644
index aaa7239..0000000
--- a/internal/config/size.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package config
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-func ParseSize(s string) (int64, error) {
- s = strings.TrimSpace(strings.ToLower(s))
- if n, err := strconv.ParseInt(s, 10, 64); err == nil {
- if n <= 0 {
- return 0, fmt.Errorf("size must be positive")
- }
- return n, nil
- }
-
- var mult int64
- switch {
- case strings.HasSuffix(s, "kib"):
- mult, s = 1<<10, s[:len(s)-3]
- case strings.HasSuffix(s, "mib"):
- mult, s = 1<<20, s[:len(s)-3]
- case strings.HasSuffix(s, "gib"):
- mult, s = 1<<30, s[:len(s)-3]
- case strings.HasSuffix(s, "k"):
- mult, s = 1<<10, s[:len(s)-1]
- case strings.HasSuffix(s, "m"):
- mult, s = 1<<20, s[:len(s)-1]
- case strings.HasSuffix(s, "g"):
- mult, s = 1<<30, s[:len(s)-1]
- default:
- return 0, fmt.Errorf("invalid size %q (expected e.g. 64MiB)", s)
- }
-
- n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
- if err != nil || n <= 0 {
- return 0, fmt.Errorf("invalid size %q (expected e.g. 64MiB)", s)
- }
- return n * mult, nil
-}
diff --git a/internal/config/size_test.go b/internal/config/size_test.go
deleted file mode 100644
index 2a9ea8b..0000000
--- a/internal/config/size_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package config
-
-import "testing"
-
-func TestParseSize(t *testing.T) {
- cases := []struct {
- in string
- want int64
- wantErr bool
- }{
- {"64MiB", 64 << 20, false},
- {"64mib", 64 << 20, false},
- {"512K", 512 << 10, false},
- {"1GiB", 1 << 30, false},
- {"4096", 4096, false},
- {"", 0, true},
- {"64GiB", 64 << 30, false},
- {"abc", 0, true},
- {"-1MiB", 0, true},
- {"64TiB", 0, true},
- }
- for _, tc := range cases {
- got, err := ParseSize(tc.in)
- if tc.wantErr && err == nil {
- t.Errorf("ParseSize(%q): expected error", tc.in)
- }
- if !tc.wantErr && (err != nil || got != tc.want) {
- t.Errorf("ParseSize(%q) = %d, %v; want %d", tc.in, got, err, tc.want)
- }
- }
-}
diff --git a/internal/dnsserver/capture_test.go b/internal/dnsserver/capture_test.go
deleted file mode 100644
index 914f19d..0000000
--- a/internal/dnsserver/capture_test.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// //----------------------------------------------------------------------------
-// // NOTICE: to save development time, test files (including this) have been
-// // generated with LLMs. The author(s) do not claim credit for these tests
-// // and exist purely for maximising code quality and reliability
-// //
-// // For more information please see `/.github/AI_USAGE.md`
-// //----------------------------------------------------------------------------//
-
-package dnsserver
-
-import (
- "context"
- "net/netip"
- "strings"
- "testing"
- "time"
-
- "github.com/miekg/dns"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- "github.com/lachlanharrisdev/gonetsim/internal/service"
- "github.com/lachlanharrisdev/gonetsim/internal/testutil"
-)
-
-func TestService_Captures(t *testing.T) {
- for _, network := range []string{"udp", "tcp"} {
- t.Run(network, func(t *testing.T) {
- conf := baseCaptureConfig(t, network)
- conf.Capture = true
- run, path := testutil.NewPcapRun(t)
-
- svc, errCh := startDNSService(t, conf, run)
-
- query := newAQuery()
- client := &dns.Client{Net: network, Timeout: 1 * time.Second}
- if _, _, err := testutil.RetryDNSExchange(t, client, conf.Addr, query); err != nil {
- t.Fatalf("exchange: %v", err)
- }
-
- testutil.WaitForPayloadContains(t, path, "example", 3*time.Second)
- testutil.WaitForPayload(t, path, 3*time.Second, func(s string) bool {
- return strings.Count(s, "example") >= 2 // query + response
- })
-
- _ = svc.Stop(context.Background())
- testutil.DiscardServiceStartErr(t, errCh)
- })
- }
-}
-
-func baseCaptureConfig(t *testing.T, network string) Config {
- t.Helper()
- return Config{
- Addr: testutil.FreePort(t, "tcp"),
- Net: network,
- SinkholeIPv4: netip.MustParseAddr("203.0.113.10"),
- SinkholeIPv6: netip.MustParseAddr("2001:db8::10"),
- SinkholeDomain: "localhost",
- SinkholeTXT: "test",
- TTL: 60,
- Compress: false,
- }
-}
-
-func newAQuery() *dns.Msg {
- m := new(dns.Msg)
- m.SetQuestion("example.com.", dns.TypeA)
- return m
-}
-
-func startDNSService(t *testing.T, conf Config, run *capture.Run) (service.Service, <-chan error) {
- t.Helper()
- logger := testutil.Logger()
- svc := NewService(conf, logger, run)
-
- errCh := make(chan error, 1)
- go func() { errCh <- svc.Start(context.Background()) }()
- return svc, errCh
-}
diff --git a/internal/dnsserver/config.go b/internal/dnsserver/config.go
deleted file mode 100644
index 09f45ee..0000000
--- a/internal/dnsserver/config.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package dnsserver
-
-import (
- "errors"
- "net"
- "net/netip"
- "time"
-
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
-)
-
-const AutoIPv4 = "auto"
-
-func AutoSinkholeIPv4() netip.Addr {
- addrs, err := net.InterfaceAddrs()
- if err == nil {
- for _, a := range addrs {
- ipnet, ok := a.(*net.IPNet)
- if !ok {
- continue
- }
- // AddrFromSlice returns ipv4 addresses as 4-in-6, unmap for Is4
- addr, ok := netip.AddrFromSlice(ipnet.IP)
- if !ok {
- continue
- }
- addr = addr.Unmap()
- if addr.Is4() && !addr.IsLoopback() && addr.IsGlobalUnicast() {
- return addr
- }
- }
- }
- return netip.MustParseAddr("127.0.0.1")
-}
-
-type Config struct {
- Addr string
- Net string
-
- SinkholeIPv4 netip.Addr
- SinkholeIPv6 netip.Addr
- SinkholeDomain string
- SinkholeTXT string
- TTL uint32
- Compress bool
- Capture bool
-}
-
-// how long to keep a UDP capture writer open after its lastdatagram
-const flowIdle = 5 * time.Minute
-
-func (c Config) Validate() error {
- if c.Addr == "" {
- return errors.New("listen addr is required")
- }
- if err := netx.ValidateNetwork(c.Net, "udp", "tcp", "both"); err != nil {
- return err
- }
- if !c.SinkholeIPv4.IsValid() {
- return errors.New("sinkhole ipv4 is required")
- }
- if c.SinkholeDomain == "" {
- return errors.New("sinkhole domain is required")
- }
- if c.SinkholeTXT == "" {
- return errors.New("sinkhole TXT is required")
- }
-
- return nil
-}
diff --git a/internal/dnsserver/dns_test.go b/internal/dnsserver/dns_test.go
deleted file mode 100644
index 2621b4e..0000000
--- a/internal/dnsserver/dns_test.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package dnsserver
-
-import (
- "fmt"
- "net"
- "net/netip"
- "testing"
- "time"
-
- "github.com/miekg/dns"
-
- "github.com/lachlanharrisdev/gonetsim/internal/testutil"
-)
-
-// not a test in of itself; sets up config and server for all record-specific tests (e.g. A, AAAA, TXT) to use, to avoid duplication of setup code in each test
-func queryTestsHelper(t *testing.T) (client *dns.Client, addr string, config Config, teardown func()) {
- pc, err := net.ListenPacket("udp", "127.0.0.1:0")
- if err != nil {
- // failed to listen on a local udp port with error
- t.Fatalf("ListenPacket: %v", err)
- }
- addr = pc.LocalAddr().String()
-
- conf := Config{
- Addr: addr,
- Net: "udp",
- SinkholeIPv4: netip.MustParseAddr("203.0.113.10"),
- SinkholeIPv6: netip.MustParseAddr("2001:db8::10"),
- SinkholeDomain: "localhost",
- SinkholeTXT: "test",
- TTL: 60,
- Compress: false,
- }
- logger := testutil.Logger()
- srv, err := NewServer(conf, logger)
- if err != nil {
- // failed to create server with error
- _ = pc.Close()
- t.Fatalf("New: %v", err)
- }
-
- srv.PacketConn = pc
-
- errCh := make(chan error, 1)
- go func() {
- errCh <- srv.ActivateAndServe()
- }()
- teardown = func() {
- err = srv.Shutdown()
- if err != nil {
- // failed to shutdown server with error
- t.Fatalf("Shutdown: %v", err)
- }
- select {
- case <-errCh:
- case <-time.After(500 * time.Millisecond):
- }
- }
-
- client = &dns.Client{Net: "udp", Timeout: 1 * time.Second}
-
- return client, addr, conf, teardown
-}
-
-func queryBothTransportsHelper(t *testing.T) (udpClient *dns.Client, tcpClient *dns.Client, addr string, config Config, teardown func()) {
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("Listen: %v", err)
- }
- port := ln.Addr().(*net.TCPAddr).Port
-
- pc, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port))
- if err != nil {
- _ = ln.Close()
- t.Fatalf("ListenPacket: %v", err)
- }
-
- addr = fmt.Sprintf("127.0.0.1:%d", port)
-
- conf := Config{
- Addr: addr,
- Net: "both",
- SinkholeIPv4: netip.MustParseAddr("203.0.113.10"),
- SinkholeIPv6: netip.MustParseAddr("2001:db8::10"),
- SinkholeDomain: "localhost",
- SinkholeTXT: "test",
- TTL: 60,
- Compress: false,
- }
- logger := testutil.Logger()
-
- srvs, err := NewServers(conf, logger)
- if err != nil {
- _ = pc.Close()
- _ = ln.Close()
- t.Fatalf("NewServers: %v", err)
- }
- for _, srv := range srvs {
- switch srv.Net {
- case "udp":
- srv.PacketConn = pc
- case "tcp":
- srv.Listener = ln
- default:
- _ = pc.Close()
- _ = ln.Close()
- t.Fatalf("unexpected server net: %q", srv.Net)
- }
- }
-
- errCh := make(chan error, len(srvs))
- for _, srv := range srvs {
- srv := srv
- go func() {
- errCh <- srv.ActivateAndServe()
- }()
- }
-
- teardown = func() {
- for _, srv := range srvs {
- if err := srv.Shutdown(); err != nil {
- t.Fatalf("Shutdown: %v", err)
- }
- }
- _ = pc.Close()
- _ = ln.Close()
-
- for i := 0; i < len(srvs); i++ {
- select {
- case <-errCh:
- case <-time.After(500 * time.Millisecond):
- }
- }
- }
-
- udpClient = &dns.Client{Net: "udp", Timeout: 1 * time.Second}
- tcpClient = &dns.Client{Net: "tcp", Timeout: 1 * time.Second}
-
- return udpClient, tcpClient, addr, conf, teardown
-}
-
-func TestAutoSinkholeIPv4(t *testing.T) {
- addr := AutoSinkholeIPv4()
- if !addr.IsValid() || !addr.Is4() {
- t.Fatalf("expected a valid IPv4 address, got %v", addr)
- }
-}
-
-func TestRecordTypes(t *testing.T) {
- cases := []struct {
- name string
- qname string
- qtype uint16
- check func(t *testing.T, resp *dns.Msg, conf Config)
- }{
- {"wildcard", "random-beacon-9f3a.malware.example.", dns.TypeA, checkA},
- {"A", "example.com.", dns.TypeA, checkA},
- {"AAAA", "example.com.", dns.TypeAAAA, checkAAAA},
- {"TXT", "example.com.", dns.TypeTXT, checkTXT},
- {"CNAME", "example.com.", dns.TypeCNAME, checkDomainTarget},
- {"MX", "example.com.", dns.TypeMX, checkDomainTarget},
- {"NS", "example.com.", dns.TypeNS, checkDomainTarget},
- {"SRV", "_sip._tcp.example.com.", dns.TypeSRV, checkDomainTarget},
- {"PTR", "example.com.", dns.TypePTR, checkDomainTarget},
- {"SOA", "example.com.", dns.TypeSOA, checkSOA},
- {"CAA", "example.com.", dns.TypeCAA, checkCAA},
- }
- client, addr, conf, teardown := queryTestsHelper(t)
- defer teardown()
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- resp := exchange(t, client, addr, tc.qname, tc.qtype)
- if len(resp.Answer) != 1 {
- t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
- }
- tc.check(t, resp, conf)
- })
- }
-}
-
-func checkA(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- a, ok := resp.Answer[0].(*dns.A)
- if !ok {
- t.Fatalf("expected *dns.A, got %T", resp.Answer[0])
- }
- if got := a.A.String(); got != conf.SinkholeIPv4.String() {
- t.Fatalf("expected %s, got %s", conf.SinkholeIPv4.String(), got)
- }
-}
-
-func checkAAAA(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- aaaa, ok := resp.Answer[0].(*dns.AAAA)
- if !ok {
- t.Fatalf("expected *dns.AAAA, got %T", resp.Answer[0])
- }
- if got := aaaa.AAAA.String(); got != conf.SinkholeIPv6.String() {
- t.Fatalf("expected %s, got %s", conf.SinkholeIPv6.String(), got)
- }
-}
-
-func checkTXT(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- txt, ok := resp.Answer[0].(*dns.TXT)
- if !ok {
- t.Fatalf("expected *dns.TXT, got %T", resp.Answer[0])
- }
- if len(txt.Txt) != 1 {
- t.Fatalf("expected 1 TXT record, got %d", len(txt.Txt))
- }
- if got := txt.Txt[0]; got != conf.SinkholeTXT {
- t.Fatalf("expected %s, got %s", conf.SinkholeTXT, got)
- }
-}
-
-func checkDomainTarget(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- var actual string
- switch rr := resp.Answer[0].(type) {
- case *dns.CNAME:
- actual = rr.Target
- case *dns.MX:
- actual = rr.Mx
- case *dns.NS:
- actual = rr.Ns
- case *dns.SRV:
- actual = rr.Target
- case *dns.PTR:
- actual = rr.Ptr
- default:
- t.Fatalf("unexpected type %T", resp.Answer[0])
- }
- if want := conf.SinkholeDomain + "."; actual != want {
- t.Fatalf("expected %s, got %s", want, actual)
- }
-}
-
-func checkSOA(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- soa, ok := resp.Answer[0].(*dns.SOA)
- if !ok {
- t.Fatalf("expected *dns.SOA, got %T", resp.Answer[0])
- }
- if got := soa.Ns; got != conf.SinkholeDomain+"." {
- t.Fatalf("expected localhost., got %s", got)
- }
- if got := soa.Mbox; got != fmt.Sprintf("hostmaster.%s.", conf.SinkholeDomain) {
- t.Fatalf("expected hostmaster.%s., got %s", conf.SinkholeDomain, got)
- }
-}
-
-func checkCAA(t *testing.T, resp *dns.Msg, conf Config) {
- t.Helper()
- caa, ok := resp.Answer[0].(*dns.CAA)
- if !ok {
- t.Fatalf("expected *dns.CAA, got %T", resp.Answer[0])
- }
- if got := caa.Value; got != conf.SinkholeDomain {
- t.Fatalf("expected %s, got %s", conf.SinkholeDomain, got)
- }
- if got := caa.Tag; got != "issue" {
- t.Fatalf("expected tag issue, got %s", got)
- }
-}
-
-func TestQueryOverUDPAndTCP(t *testing.T) {
- udpClient, tcpClient, addr, config, teardown := queryBothTransportsHelper(t)
- defer teardown()
-
- assertA := func(resp *dns.Msg) {
- t.Helper()
- if len(resp.Answer) != 1 {
- t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
- }
- a, ok := resp.Answer[0].(*dns.A)
- if !ok {
- t.Fatalf("expected *dns.A, got %T", resp.Answer[0])
- }
- if got := a.A.String(); got != config.SinkholeIPv4.String() {
- t.Fatalf("expected %s, got %s", config.SinkholeIPv4.String(), got)
- }
- }
-
- respUDP := exchange(t, udpClient, addr, "example.com.", dns.TypeA)
- respTCP := exchange(t, tcpClient, addr, "example.com.", dns.TypeA)
-
- assertA(respUDP)
- assertA(respTCP)
-}
-
-func exchange(t *testing.T, client *dns.Client, addr, name string, qtype uint16) *dns.Msg {
- t.Helper()
-
- m := new(dns.Msg)
- m.SetQuestion(name, qtype)
-
- deadline := time.Now().Add(2 * time.Second)
- var lastErr error
- for time.Now().Before(deadline) {
- resp, _, err := client.Exchange(m, addr)
- if err == nil && resp != nil {
- return resp
- }
- lastErr = err
- time.Sleep(10 * time.Millisecond)
- }
-
- if lastErr == nil {
- lastErr = fmt.Errorf("no response")
- }
- t.Fatalf("dns exchange failed: %v", lastErr)
- return nil
-}
diff --git a/internal/dnsserver/server.go b/internal/dnsserver/server.go
deleted file mode 100644
index 1caf6f3..0000000
--- a/internal/dnsserver/server.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package dnsserver
-
-import (
- "context"
- "fmt"
- "log/slog"
- "net/netip"
- "strings"
-
- "github.com/miekg/dns"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
- "github.com/lachlanharrisdev/gonetsim/internal/service"
-)
-
-type Server struct {
- conf Config
- srvs []*dns.Server
- log *slog.Logger
- run *capture.Run
- pconns []*capture.PacketConn
-}
-
-func NewService(conf Config, logger *slog.Logger, run *capture.Run) service.Service {
- if !conf.Capture {
- run = nil
- }
- return &Server{conf: conf, log: service.NewPrefixedLogger(logger, "DNS"), run: run}
-}
-
-func (s *Server) Name() string {
- return "DNS"
-}
-
-func NewServers(conf Config, logger *slog.Logger) ([]*dns.Server, error) {
- h := &handler{
- logger: logger,
- sinkholeIPv4: conf.SinkholeIPv4,
- sinkholeIPv6: conf.SinkholeIPv6,
- sinkholeDomain: conf.SinkholeDomain,
- sinkholeTXT: conf.SinkholeTXT,
- ttl: conf.TTL,
- compress: conf.Compress,
- }
-
- // catch-all
- mux := dns.NewServeMux()
- mux.HandleFunc(".", h.handle)
-
- network := strings.ToLower(strings.TrimSpace(conf.Net))
- switch network {
- case "udp", "tcp":
- return []*dns.Server{{Addr: conf.Addr, Net: network, Handler: mux}}, nil
- case "both":
- return []*dns.Server{
- {Addr: conf.Addr, Net: "udp", Handler: mux},
- {Addr: conf.Addr, Net: "tcp", Handler: mux},
- }, nil
- default:
- return nil, fmt.Errorf("unsupported dns network %q (must be udp, tcp, or both)", conf.Net)
- }
-}
-
-func NewServer(conf Config, logger *slog.Logger) (*dns.Server, error) {
- srvs, err := NewServers(conf, logger)
- if err != nil {
- return nil, err
- }
- if len(srvs) != 1 {
- return nil, fmt.Errorf("expected 1 dns server, got %d", len(srvs))
- }
- return srvs[0], nil
-}
-
-func (s *Server) Start(ctx context.Context) error {
- logger := s.log
-
- srvs, err := NewServers(s.conf, logger)
- if err != nil {
- return err
- }
- s.srvs = srvs
-
- for _, srv := range srvs {
- iface, err := s.run.NewInterface("gonetsim dns " + srv.Net)
- if err != nil {
- return err
- }
- switch srv.Net {
- case "udp":
- wrapped, err := netx.ListenUDP(s.conf.Addr, s.run, iface, flowIdle)
- if err != nil {
- return err
- }
- srv.PacketConn = wrapped
- s.pconns = append(s.pconns, wrapped)
- case "tcp":
- ln, err := netx.ListenTCP(s.conf.Addr, s.run, iface, nil)
- if err != nil {
- return err
- }
- srv.Listener = ln
- default:
- return fmt.Errorf("unsupported dns network %q", srv.Net)
- }
- }
-
- logger.Info("listening", "on", s.conf.Addr, "net", netx.DisplayNetwork(s.conf.Net), "sinkhole", sinkholeSummary(s.conf))
-
- errCh := make(chan error, len(srvs))
- for _, srv := range srvs {
- srv := srv
- go func() {
- errCh <- srv.ActivateAndServe()
- }()
- }
-
- var retErr error
- for i := 0; i < len(srvs); i++ {
- err := <-errCh
- if err != nil && retErr == nil {
- retErr = err
- for _, srv := range srvs {
- _ = srv.Shutdown()
- }
- }
- }
- return retErr
-}
-
-func (s *Server) Stop(ctx context.Context) error {
- if len(s.srvs) == 0 {
- return nil
- }
-
- var firstErr error
- for _, srv := range s.srvs {
- if err := srv.ShutdownContext(ctx); err != nil && firstErr == nil {
- firstErr = err
- }
- }
- for _, pc := range s.pconns {
- pc.CloseAll()
- }
- s.pconns = nil
- s.srvs = nil
- return firstErr
-}
-
-func sinkholeSummary(conf Config) string {
- parts := []string{conf.SinkholeIPv4.String()}
- if conf.SinkholeIPv6.IsValid() {
- parts = append(parts, conf.SinkholeIPv6.String())
- }
- return strings.Join(parts, ",")
-}
-
-type handler struct {
- logger *slog.Logger
-
- sinkholeIPv4 netip.Addr
- sinkholeIPv6 netip.Addr
- sinkholeDomain string
- sinkholeTXT string
- ttl uint32
- compress bool
-}
-
-func (h *handler) handle(w dns.ResponseWriter, r *dns.Msg) {
- logger := h.logger
-
- m := new(dns.Msg)
- m.SetReply(r)
- m.Compress = h.compress
-
- for _, q := range r.Question {
- qtype := dns.TypeToString[q.Qtype]
- logger.Info(qtype, "src", w.RemoteAddr().String(), "to", strings.TrimSuffix(q.Name, "."))
- switch q.Qtype {
- case dns.TypeA:
- appendRecord(logger, m, q, h.ttl, "A", h.sinkholeIPv4.String())
- case dns.TypeAAAA:
- if h.sinkholeIPv6.IsValid() {
- appendRecord(logger, m, q, h.ttl, "AAAA", h.sinkholeIPv6.String())
- }
- case dns.TypeCNAME:
- appendRecord(logger, m, q, h.ttl, "CNAME", h.sinkholeDomain)
- case dns.TypeMX:
- appendRecord(logger, m, q, h.ttl, "MX", "10 "+h.sinkholeDomain)
- case dns.TypeTXT:
- appendRecord(logger, m, q, h.ttl, "TXT", h.sinkholeTXT)
- case dns.TypeNS:
- appendRecord(logger, m, q, h.ttl, "NS", h.sinkholeDomain)
- case dns.TypeSRV:
- appendRecord(logger, m, q, h.ttl, "SRV", "10 0 0 "+h.sinkholeDomain)
- case dns.TypePTR:
- appendRecord(logger, m, q, h.ttl, "PTR", h.sinkholeDomain)
- case dns.TypeSOA:
- appendRecord(logger, m, q, h.ttl, "SOA", fmt.Sprintf("%s. hostmaster.%s. 1 3600 600 604800 3600", h.sinkholeDomain, h.sinkholeDomain))
- case dns.TypeCAA:
- appendRecord(logger, m, q, h.ttl, "CAA", fmt.Sprintf("0 issue \"%s\"", h.sinkholeDomain))
- default:
- // ret NOERROR with empty Answer for other types
- }
- }
-
- _ = w.WriteMsg(m)
-}
-
-func appendRecord(logger *slog.Logger, m *dns.Msg, q dns.Question, ttl uint32, rrType, data string) {
- record := fmt.Sprintf("%s %d IN %s %s", q.Name, ttl, rrType, data)
- if rr, err := dns.NewRR(record); err == nil {
- m.Answer = append(m.Answer, rr)
- } else {
- logger.Error("failed to create record", "type", rrType, "name", q.Name, "err", err)
- }
-}
diff --git a/internal/handler/echo.go b/internal/handler/echo.go
deleted file mode 100644
index 93b1c3e..0000000
--- a/internal/handler/echo.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package handler
-
-import (
- "context"
- "net"
-)
-
-type EchoHandler struct{}
-
-func (EchoHandler) HandleTCP(_ context.Context, conn net.Conn, env Env) error {
- buf := make([]byte, 32*1024)
- for {
- n, err := conn.Read(buf)
- if n > 0 {
- if _, werr := conn.Write(buf[:n]); werr != nil {
- return werr
- }
- }
- if err != nil {
- return readError(err)
- }
- }
-}
-
-func (EchoHandler) HandleUDP(_ context.Context, data []byte, _ net.Addr, env Env) ([]byte, error) {
- return data, nil
-}
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
deleted file mode 100644
index 6b59d37..0000000
--- a/internal/handler/handler_test.go
+++ /dev/null
@@ -1,151 +0,0 @@
-////----------------------------------------------------------------------------
-// NOTICE: to save development time, test files (including this) have been
-// generated with LLMs. The author(s) do not claim credit for these tests
-// and exist purely for maximising code quality and reliability
-//
-// For more information please see `/.github/AI_USAGE.md`
-//----------------------------------------------------------------------------//
-
-package handler
-
-import (
- "io"
- "log/slog"
- "net"
- "strings"
- "testing"
-
- "github.com/lachlanharrisdev/gonetsim/internal/state"
- "github.com/lachlanharrisdev/gonetsim/internal/testutil"
-)
-
-func testLogger() *slog.Logger {
- return testutil.Logger()
-}
-
-// servePipe runs h against one end of a pipe; the other end is returned for
-// the test to act as the client.
-func servePipe(t *testing.T, h Handler, env Env) (net.Conn, <-chan error) {
- t.Helper()
- client, server := net.Pipe()
- t.Cleanup(func() { _ = client.Close() })
- done := make(chan error, 1)
- go func() {
- done <- h.HandleTCP(t.Context(), server, env)
- }()
- return client, done
-}
-
-// roundtrip writes payload and expects reply back.
-func roundtrip(t *testing.T, client net.Conn, payload, reply string) {
- t.Helper()
- if _, err := client.Write([]byte(payload)); err != nil {
- t.Fatalf("Write: %v", err)
- }
- buf := make([]byte, len(reply))
- if _, err := io.ReadFull(client, buf); err != nil {
- t.Fatalf("ReadFull: %v", err)
- }
- if string(buf) != reply {
- t.Fatalf("unexpected reply %q, want %q", buf, reply)
- }
-}
-
-func TestBuiltins(t *testing.T) {
- t.Run("tcp echo", func(t *testing.T) {
- client, done := servePipe(t, EchoHandler{}, Env{Logger: testLogger()})
- roundtrip(t, client, "abc", "abc")
- _ = client.Close()
- if err := <-done; err != nil {
- t.Fatalf("HandleTCP: %v", err)
- }
- })
-
- t.Run("udp echo", func(t *testing.T) {
- addr, _ := net.ResolveUDPAddr("udp", "203.0.113.10:53")
- reply, err := EchoHandler{}.HandleUDP(t.Context(), []byte("query"), addr, Env{Logger: testLogger()})
- if err != nil || string(reply) != "query" {
- t.Fatalf("udp echo: %v %q", err, reply)
- }
- })
-}
-
-func TestNewSpecErrors(t *testing.T) {
- for _, spec := range []string{"", "noscheme", "builtin:nope", "python:foo.py", "lua:missing.lua", "lua:bad_syntax.lua", "lua:no_entry.lua", "lua:sandbox_escape.lua"} {
- if _, err := New(spec, "testdata", nil); err == nil {
- t.Errorf("New(%q): expected error", spec)
- }
- }
-}
-
-func TestLuaHandler(t *testing.T) {
- t.Run("tcp line roundtrip", func(t *testing.T) {
- h, err := NewLua("testdata/line_echo.lua", nil)
- if err != nil {
- t.Fatalf("NewLua: %v", err)
- }
- client, done := servePipe(t, h, Env{Logger: testLogger()})
- roundtrip(t, client, "hello\nworld\n", "echo: hello\necho: world\n")
- _ = client.Close()
- if err := <-done; err != nil {
- t.Fatalf("HandleTCP: %v", err)
- }
- })
-
- t.Run("udp packets", func(t *testing.T) {
- remote, _ := net.ResolveUDPAddr("udp", "203.0.113.10:53531")
- h, err := NewLua("testdata/packet.lua", nil)
- if err != nil {
- t.Fatalf("NewLua: %v", err)
- }
- reply, err := h.HandleUDP(t.Context(), []byte("ping"), remote, Env{Logger: testLogger()})
- if err != nil || string(reply) != "pong" {
- t.Fatalf("ping: %v %q", err, reply)
- }
- reply, err = h.HandleUDP(t.Context(), []byte("other"), remote, Env{Logger: testLogger()})
- if err != nil || reply != nil {
- t.Fatalf("silent: %v %q", err, reply)
- }
- })
-}
-
-func TestLuaState(t *testing.T) {
- h, err := NewLua("testdata/state.lua", state.NewBudget(state.DefaultTotalLimit))
- if err != nil {
- t.Fatalf("NewLua: %v", err)
- }
- env := Env{Logger: testLogger(), Global: state.NewStore(state.NewBudget(state.DefaultTotalLimit))}
- for i, want := range []string{"1|conn|yes", "2|conn|yes"} {
- client, done := servePipe(t, h, env)
- buf := make([]byte, len(want))
- if _, err := io.ReadFull(client, buf); err != nil {
- t.Fatalf("ReadFull: %v", err)
- }
- if string(buf) != want {
- t.Fatalf("connection %d = %q, want %q", i, buf, want)
- }
- _ = client.Close()
- if err := <-done; err != nil {
- t.Fatalf("HandleTCP: %v", err)
- }
- }
-}
-
-func TestSandboxGlobals(t *testing.T) {
- h, err := NewLua("testdata/sandbox_report.lua", nil)
- if err != nil {
- t.Fatalf("NewLua: %v", err)
- }
- client, done := servePipe(t, h, Env{Logger: testLogger()})
- buf := make([]byte, 1024)
- n, err := client.Read(buf)
- if err != nil {
- t.Fatalf("Read: %v", err)
- }
- reply := string(buf[:n])
- if !strings.Contains(reply, "io=nil") || !strings.Contains(reply, "os=nil") || !strings.Contains(reply, "require=nil") {
- t.Fatalf("sandbox globals leaked: %q", reply)
- }
- _ = client.Close()
- <-done
-}
diff --git a/internal/handler/luapack.go b/internal/handler/luapack.go
deleted file mode 100644
index f2f2d2e..0000000
--- a/internal/handler/luapack.go
+++ /dev/null
@@ -1,326 +0,0 @@
-package handler
-
-import (
- "encoding/binary"
- "fmt"
- "math"
- "strconv"
- "strings"
-
- lua "github.com/yuin/gopher-lua"
-)
-
-// string.pack / string.unpack: gopher-lua implements Lua 5.1, which predates
-// these; this covers the practical 5.3 subset. Alignment ("!") is rejected
-// rather than misencoded.
-
-const (
- // maxPackSize caps the size of a single packed string.
- maxPackSize = 1 << 20 // 1 MiB
-
- // defaultIntSize is the size of "i"/"I"; defaultLenSize is the length
- // prefix size of plain "s", matching the 5.3 default of size_t.
- defaultIntSize = 4
- defaultLenSize = 8
-)
-
-type packItem struct {
- op byte
- size int
- endian binary.ByteOrder
-}
-
-func parsePackFormat(f string) ([]packItem, error) {
- var items []packItem
- var endian binary.ByteOrder = binary.LittleEndian // the '=' default; native equals little here
-
- for i := 0; i < len(f); i++ {
- c := f[i]
- switch c {
- case ' ':
- case '<':
- endian = binary.LittleEndian
- case '>':
- endian = binary.BigEndian
- case '=':
- endian = binary.LittleEndian
- case '!':
- return nil, fmt.Errorf("alignment ('!') is not supported")
- case 'x':
- items = append(items, packItem{op: 'x', size: 1})
- case 'b', 'B':
- items = append(items, packItem{op: c, size: 1, endian: endian})
- case 'h', 'H':
- items = append(items, packItem{op: c, size: 2, endian: endian})
- case 'i', 'I':
- n, next, err := scanSize(f, i+1, defaultIntSize)
- if err != nil {
- return nil, err
- }
- if n < 1 || n > 8 {
- return nil, fmt.Errorf("integral size (%d) out of limits [1,8]", n)
- }
- items = append(items, packItem{op: c, size: n, endian: endian})
- i = next - 1
- case 'l', 'L', 'j', 'J':
- items = append(items, packItem{op: c, size: 8, endian: endian})
- case 'f':
- items = append(items, packItem{op: c, size: 4, endian: endian})
- case 'd', 'n':
- items = append(items, packItem{op: c, size: 8, endian: endian})
- case 's':
- n, next, err := scanSize(f, i+1, defaultLenSize)
- if err != nil {
- return nil, err
- }
- if n < 1 || n > 8 {
- return nil, fmt.Errorf("string length size (%d) out of limits [1,8]", n)
- }
- items = append(items, packItem{op: c, size: n, endian: endian})
- i = next - 1
- case 'z':
- items = append(items, packItem{op: c})
- case 'c':
- n, next, err := scanSize(f, i+1, 0)
- if err != nil {
- return nil, err
- }
- if next == i+1 {
- return nil, fmt.Errorf("missing size for format option 'c' in %q", f)
- }
- items = append(items, packItem{op: c, size: n})
- i = next - 1
- default:
- return nil, fmt.Errorf("invalid format option '%c' in %q", c, f)
- }
- }
- return items, nil
-}
-
-func scanSize(f string, i, def int) (int, int, error) {
- start := i
- for i < len(f) && f[i] >= '0' && f[i] <= '9' {
- i++
- }
- if i == start {
- return def, start, nil
- }
- n, err := strconv.Atoi(f[start:i])
- if err != nil || n > 1<<20 {
- return 0, 0, fmt.Errorf("size in format %q out of range", f)
- }
- return n, i, nil
-}
-
-func isUnsigned(op byte) bool {
- return op == 'B' || op == 'H' || op == 'I' || op == 'L' || op == 'J'
-}
-
-// checkInteger errors on non-integral numbers; gopher-lua's CheckInt64
-// would silently truncate and corrupt packed data.
-func checkInteger(L *lua.LState, arg int) int64 {
- v := float64(L.CheckNumber(arg))
- if v != math.Trunc(v) || math.IsNaN(v) || math.IsInf(v, 0) ||
- v >= 9223372036854775808.0 || v < -9223372036854775808.0 {
- L.ArgError(arg, "number has no integer representation")
- return 0
- }
- return int64(v)
-}
-
-// checkIntRange enforces the 5.3 overflow rules.
-func checkIntRange(op byte, size int, v int64) error {
- if size == 8 {
- return nil
- }
- if isUnsigned(op) {
- if v < 0 || uint64(v) > uint64(1)<<(8*size)-1 {
- return fmt.Errorf("integer overflow")
- }
- return nil
- }
- min := -(int64(1) << (8*size - 1))
- max := (int64(1) << (8*size - 1)) - 1
- if v < min || v > max {
- return fmt.Errorf("integer overflow")
- }
- return nil
-}
-
-func putInt(dst []byte, order binary.ByteOrder, v uint64, n int) {
- var buf [8]byte
- order.PutUint64(buf[:], v)
- if order == binary.BigEndian {
- copy(dst, buf[8-n:])
- } else {
- copy(dst, buf[:n])
- }
-}
-
-func getInt(src []byte, order binary.ByteOrder, n int) uint64 {
- var buf [8]byte
- if order == binary.BigEndian {
- copy(buf[8-n:], src)
- } else {
- copy(buf[:n], src)
- }
- return order.Uint64(buf[:])
-}
-
-func luaPack(L *lua.LState) int {
- f := L.CheckString(1)
- items, err := parsePackFormat(f)
- if err != nil {
- L.ArgError(1, err.Error())
- return 0
- }
-
- var buf []byte
- arg := 2
- for _, it := range items {
- switch it.op {
- case 'x':
- buf = append(buf, 0)
- case 'b', 'h', 'i', 'l', 'j', 'B', 'H', 'I', 'L', 'J':
- v := checkInteger(L, arg)
- if err := checkIntRange(it.op, it.size, v); err != nil {
- L.ArgError(arg, err.Error())
- return 0
- }
- off := len(buf)
- buf = append(buf, make([]byte, it.size)...)
- putInt(buf[off:], it.endian, uint64(v), it.size)
- case 'f':
- bits := math.Float32bits(float32(L.CheckNumber(arg)))
- off := len(buf)
- buf = append(buf, make([]byte, 4)...)
- it.endian.PutUint32(buf[off:], bits)
- case 'd', 'n':
- bits := math.Float64bits(float64(L.CheckNumber(arg)))
- off := len(buf)
- buf = append(buf, make([]byte, 8)...)
- it.endian.PutUint64(buf[off:], bits)
- case 's':
- s := L.CheckString(arg)
- if it.size < 8 && int64(len(s)) >= int64(1)<<(8*it.size) {
- L.ArgError(arg, "string longer than size")
- return 0
- }
- off := len(buf)
- buf = append(buf, make([]byte, it.size)...)
- putInt(buf[off:], it.endian, uint64(len(s)), it.size)
- buf = append(buf, s...)
- case 'z':
- s := L.CheckString(arg)
- if strings.IndexByte(s, 0) >= 0 {
- L.ArgError(arg, "string contains zeros")
- return 0
- }
- buf = append(buf, s...)
- buf = append(buf, 0)
- case 'c':
- s := L.CheckString(arg)
- if len(s) > it.size {
- L.ArgError(arg, "string longer than given size")
- return 0
- }
- buf = append(buf, s...)
- buf = append(buf, make([]byte, it.size-len(s))...)
- }
- if it.op != 'x' {
- arg++
- }
- }
-
- if len(buf) > maxPackSize {
- L.ArgError(1, fmt.Sprintf("resulting string larger than %d bytes", maxPackSize))
- return 0
- }
- L.Push(lua.LString(buf))
- return 1
-}
-
-func luaUnpack(L *lua.LState) int {
- f := L.CheckString(1)
- data := L.CheckString(2)
- items, err := parsePackFormat(f)
- if err != nil {
- L.ArgError(1, err.Error())
- return 0
- }
-
- init := 1
- if L.GetTop() >= 3 {
- init = L.CheckInt(3)
- }
- if init < 0 {
- init = len(data) + init + 1
- }
- if init < 1 || init > len(data)+1 {
- L.ArgError(3, "initial position out of string")
- return 0
- }
- off := init - 1
-
- var results []lua.LValue
- for _, it := range items {
- if off+it.size > len(data) {
- L.RaiseError("data string too short")
- return 0
- }
-
- switch it.op {
- case 'x':
- off++
- case 'b', 'h', 'i', 'l', 'j':
- v := int64(getInt([]byte(data[off:off+it.size]), it.endian, it.size))
- results = append(results, lua.LNumber(signExtend(v, it.size)))
- off += it.size
- case 'B', 'H', 'I', 'L', 'J':
- results = append(results, lua.LNumber(getInt([]byte(data[off:off+it.size]), it.endian, it.size)))
- off += it.size
- case 'f':
- bits := it.endian.Uint32([]byte(data[off : off+4]))
- results = append(results, lua.LNumber(math.Float32frombits(bits)))
- off += 4
- case 'd', 'n':
- bits := it.endian.Uint64([]byte(data[off : off+8]))
- results = append(results, lua.LNumber(math.Float64frombits(bits)))
- off += 8
- case 's':
- n := int(getInt([]byte(data[off:off+it.size]), it.endian, it.size))
- off += it.size
- if n < 0 || off+n > len(data) {
- L.RaiseError("data string too short")
- return 0
- }
- results = append(results, lua.LString(data[off:off+n]))
- off += n
- case 'z':
- end := strings.IndexByte(data[off:], 0)
- if end < 0 {
- L.RaiseError("no zero terminator found")
- return 0
- }
- results = append(results, lua.LString(data[off:off+end]))
- off += end + 1
- case 'c':
- results = append(results, lua.LString(data[off:off+it.size]))
- off += it.size
- }
- }
-
- for _, v := range results {
- L.Push(v)
- }
- L.Push(lua.LNumber(off + 1))
- return len(results) + 1
-}
-
-func signExtend(v int64, size int) int64 {
- if size == 8 {
- return v
- }
- shift := 64 - 8*size
- return v << shift >> shift
-}
diff --git a/internal/handler/sink.go b/internal/handler/sink.go
deleted file mode 100644
index 790ed1f..0000000
--- a/internal/handler/sink.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package handler
-
-import (
- "context"
- "net"
-)
-
-// SinkHandler consumes and discards all received data.
-type SinkHandler struct{}
-
-func (SinkHandler) HandleTCP(_ context.Context, conn net.Conn, env Env) error {
- buf := make([]byte, 32*1024)
- for {
- _, err := conn.Read(buf)
- if err != nil {
- return readError(err)
- }
- }
-}
-
-func (SinkHandler) HandleUDP(_ context.Context, data []byte, _ net.Addr, env Env) ([]byte, error) {
- return nil, nil
-}
diff --git a/internal/handler/testdata/bad_syntax.lua b/internal/handler/testdata/bad_syntax.lua
deleted file mode 100644
index e997ef7..0000000
--- a/internal/handler/testdata/bad_syntax.lua
+++ /dev/null
@@ -1 +0,0 @@
-function handle(conn
diff --git a/internal/handler/testdata/comment.lua b/internal/handler/testdata/comment.lua
deleted file mode 100644
index ad58745..0000000
--- a/internal/handler/testdata/comment.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- comment on the next frame the client sends
-function handle(conn)
- local data = conn:read(1024)
- capture:comment("client sent " .. #data .. " bytes")
-end
\ No newline at end of file
diff --git a/internal/handler/testdata/isolated.lua b/internal/handler/testdata/isolated.lua
deleted file mode 100644
index 9a5eaa5..0000000
--- a/internal/handler/testdata/isolated.lua
+++ /dev/null
@@ -1,9 +0,0 @@
--- Errors on "boom", serves "ok" otherwise. Used to verify per-connection
--- error isolation.
-function handle(conn)
- local line = conn:read_line()
- if line == "boom\n" then
- error("intentional test failure")
- end
- conn:write("ok\n")
-end
diff --git a/internal/handler/testdata/line_echo.lua b/internal/handler/testdata/line_echo.lua
deleted file mode 100644
index 3c1a249..0000000
--- a/internal/handler/testdata/line_echo.lua
+++ /dev/null
@@ -1,8 +0,0 @@
--- Echo every line back with a prefix; exit cleanly on EOF.
-function handle(conn)
- while true do
- local line = conn:read_line()
- if not line then break end
- conn:write("echo: " .. line)
- end
-end
diff --git a/internal/handler/testdata/no_entry.lua b/internal/handler/testdata/no_entry.lua
deleted file mode 100644
index 43db101..0000000
--- a/internal/handler/testdata/no_entry.lua
+++ /dev/null
@@ -1,2 +0,0 @@
--- Defines no entrypoint; must be rejected at startup.
-local nothing = 42
diff --git a/internal/handler/testdata/packet.lua b/internal/handler/testdata/packet.lua
deleted file mode 100644
index 8161439..0000000
--- a/internal/handler/testdata/packet.lua
+++ /dev/null
@@ -1,8 +0,0 @@
--- Reply to a ping, otherwise stay silent.
-function handle_packet(data, peer)
- if data == "ping" then
- log:info("ping from " .. peer.addr)
- return "pong"
- end
- return nil
-end
diff --git a/internal/handler/testdata/read_n.lua b/internal/handler/testdata/read_n.lua
deleted file mode 100644
index abe96d5..0000000
--- a/internal/handler/testdata/read_n.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- Read exactly 4 bytes and report them.
-function handle(conn)
- local data = conn:read(4)
- conn:write("got:" .. data)
-end
diff --git a/internal/handler/testdata/read_until.lua b/internal/handler/testdata/read_until.lua
deleted file mode 100644
index 5bd3497..0000000
--- a/internal/handler/testdata/read_until.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- Reads HTTP-style headers and reports their byte length.
-function handle(conn)
- local data = conn:read_until("\r\n\r\n")
- conn:write("len:" .. #data)
-end
diff --git a/internal/handler/testdata/sandbox_escape.lua b/internal/handler/testdata/sandbox_escape.lua
deleted file mode 100644
index 6d90c38..0000000
--- a/internal/handler/testdata/sandbox_escape.lua
+++ /dev/null
@@ -1,6 +0,0 @@
--- Fails to load: the sandbox removes io, so indexing it errors at load time.
-local escape = io.write("sandbox escape attempt")
-
-function handle(conn)
- conn:write("never runs")
-end
diff --git a/internal/handler/testdata/sandbox_report.lua b/internal/handler/testdata/sandbox_report.lua
deleted file mode 100644
index 3611ee3..0000000
--- a/internal/handler/testdata/sandbox_report.lua
+++ /dev/null
@@ -1,4 +0,0 @@
--- Proves the sandbox: io and os are unavailable at runtime.
-function handle(conn)
- conn:write("io=" .. tostring(io) .. " os=" .. tostring(os) .. " require=" .. tostring(require))
-end
diff --git a/internal/handler/testdata/sleep.lua b/internal/handler/testdata/sleep.lua
deleted file mode 100644
index 1617ea7..0000000
--- a/internal/handler/testdata/sleep.lua
+++ /dev/null
@@ -1,9 +0,0 @@
--- Sleeps between two reads, to verify sleep timing and that the idle
--- deadline is reset across the sleep.
-function handle(conn)
- conn:read(1024)
- conn:sleep(200)
- local data = conn:read(1024)
- if not data then return end
- conn:write("after-sleep")
-end
diff --git a/internal/handler/testdata/sleep_cap.lua b/internal/handler/testdata/sleep_cap.lua
deleted file mode 100644
index 5ac4caa..0000000
--- a/internal/handler/testdata/sleep_cap.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- Sleeps far beyond the cap; the call must fail.
-function handle(conn)
- conn:read(1024)
- conn:sleep(3600001)
-end
diff --git a/internal/handler/testdata/sni.lua b/internal/handler/testdata/sni.lua
deleted file mode 100644
index 4adedcd..0000000
--- a/internal/handler/testdata/sni.lua
+++ /dev/null
@@ -1,9 +0,0 @@
--- Replies with the client's TLS SNI, or "no-sni" on plain connections.
-function handle(conn)
- local sni = conn:sni()
- if sni then
- conn:write("sni:" .. sni)
- else
- conn:write("no-sni")
- end
-end
diff --git a/internal/handler/testdata/state.lua b/internal/handler/testdata/state.lua
deleted file mode 100644
index db3a98b..0000000
--- a/internal/handler/testdata/state.lua
+++ /dev/null
@@ -1,12 +0,0 @@
--- Exercises the conn/handler/global state scopes; handler visits persist
--- across connections, global is shared process-wide.
-function handle(conn)
- local visits = tonumber(handler:get("visits")) or 0
- visits = visits + 1
- handler:set("visits", tostring(visits))
-
- conn:set("mark", "conn")
- global:set("seen", "yes")
-
- conn:write(visits .. "|" .. tostring(conn:get("mark")) .. "|" .. tostring(global:get("seen")))
-end
diff --git a/internal/httpserver/capture_test.go b/internal/httpserver/capture_test.go
deleted file mode 100644
index c445ae4..0000000
--- a/internal/httpserver/capture_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-// //----------------------------------------------------------------------------
-// // NOTICE: to save development time, test files (including this) have been
-// // generated with LLMs. The author(s) do not claim credit for these tests
-// // and exist purely for maximising code quality and reliability
-// //
-// // For more information please see `/.github/AI_USAGE.md`
-// //----------------------------------------------------------------------------//
-
-package httpserver
-
-import (
- "context"
- "io"
- "net/http"
- "testing"
- "time"
-
- "github.com/lachlanharrisdev/gonetsim/internal/capture"
- "github.com/lachlanharrisdev/gonetsim/internal/service"
- "github.com/lachlanharrisdev/gonetsim/internal/testutil"
-)
-
-func TestService_CapturesHTTP(t *testing.T) {
- conf := Config{
- Addr: testutil.FreeTCPAddr(t),
- StatusCode: http.StatusOK,
- Mode: "fake",
- Capture: true,
- }
- run, path := testutil.NewPcapRun(t)
- svc, errCh := startHTTPService(t, conf, run)
-
- get := func(url string) *http.Response {
- _, resp := testutil.RetryGet(t, http.DefaultClient, url)
- return resp
- }
- get("http://" + conf.Addr + "/warmup")
- resp := get("http://" + conf.Addr + "/hello")
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
-
- testutil.WaitForPayloadContains(t, path, "GET /hello", 3*time.Second)
- testutil.WaitForPayloadContains(t, path, "HTTP/1.1 200", 3*time.Second)
-
- _ = svc.Stop(context.Background())
- testutil.DiscardServiceStartErr(t, errCh)
-}
-
-func startHTTPService(t *testing.T, conf Config, run *capture.Run) (service.Service, <-chan error) {
- t.Helper()
- logger := testutil.Logger()
- svc := NewService(conf, logger, run)
-
- errCh := make(chan error, 1)
- go func() { errCh <- svc.Start(context.Background()) }()
- return svc, errCh
-}
diff --git a/internal/httpserver/config.go b/internal/httpserver/config.go
deleted file mode 100644
index 287eb08..0000000
--- a/internal/httpserver/config.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package httpserver
-
-import (
- "errors"
- "fmt"
- "os"
-
- "github.com/lachlanharrisdev/gonetsim/internal/netx"
- "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider"
-)
-
-type Config struct {
- Addr string
-
- // enables https if not nil
- TLS *tlsprovider.Config
-
- // if non-empty, a fixed status code returned for all requests
- // when zero, defaults to 200
- StatusCode int
-
- // allow switching between modes
- // more info in the docs: https://gonetsim.lachlanharris.dev/reference/http
- Mode string
-
- // root directory to serve files
- // only used in real mode
- RootDir string
-
- // write every connection to the run pcapng file
- Capture bool
-}
-
-// normalize fills in defaults that can't be expressed as zero values.
-// Mode defaults to "fake" when empty (for backwards compatibility with
-// configs created before the mode/root_dir options existed).
-func (c Config) normalize() Config {
- if c.Mode == "" {
- c.Mode = "fake"
- }
- return c
-}
-
-func (c Config) Validate() error {
- if c.Addr == "" {
- return errors.New("listen addr is required")
- }
- if err := netx.ValidateStatus(c.StatusCode); err != nil {
- return err
- }
- if c.TLS != nil {
- if err := c.TLS.Validate(); err != nil {
- return err
- }
- }
- switch c.Mode {
- case "", "fake":
- // ok
- case "real":
- if c.RootDir == "" {
- return errors.New("real mode requires root_dir to be set")
- }
- if _, err := os.Stat(c.RootDir); os.IsNotExist(err) {
- return fmt.Errorf("root_dir %q does not exist", c.RootDir)
- }
- default:
- return fmt.Errorf("mode can only be 'fake' or 'real', was %q", c.Mode)
- }
- return nil
-}
diff --git a/internal/httpserver/content/index.css b/internal/httpserver/content/index.css
deleted file mode 100644
index b7a2d06..0000000
--- a/internal/httpserver/content/index.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* GoNetSim HTTP server
-
-If you're seeing this file, the GoNetSim HTTP Server is running successfully in fake mode
-
-Links
-- Repository: https://github.com/lachlanharris/gonetsim
-- HTTP reference: https://gonetsim.lachlanharris.dev/reference/http
-- HTTPS reference: https://gonetsim.lachlanharris.dev/reference/https
-
-Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
-*/
-
-.fake {
- font-family: sans-serif;
-}
\ No newline at end of file
diff --git a/internal/httpserver/content/index.html b/internal/httpserver/content/index.html
deleted file mode 100644
index 553b07f..0000000
--- a/internal/httpserver/content/index.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
- GoNetSim HTTP Server
-
-
-
-
-
GoNetSim HTTP Server
-
-
-
If you're seeing this page, the GoNetSim HTTP Server is running successfully in fake mode. To customise this server, please see the HTTP reference and/or the HTTPS reference.