From aa7820a51c163f2681786cf4ffde541cff2c5ed0 Mon Sep 17 00:00:00 2001 From: Lachlan Harris Date: Thu, 10 Sep 2026 18:30:28 +1000 Subject: [PATCH] feat: reduce LOC by ~6000 --- .devcontainer/setup.sh | 4 +- .github/workflows/ci.yaml | 50 +-- .github/workflows/release.yaml | 20 +- .gitignore | 10 +- .golangci.yaml | 13 + .goreleaser.yaml | 95 ++--- .ko.yaml | 4 +- CITATION.cff | 17 - README.md | 182 +++------ cmd/check.go | 248 ------------ cmd/docs.go | 58 ++- cmd/license.go | 21 - cmd/pcap.go | 112 ------ cmd/pcap_test.go | 113 ------ cmd/root.go | 74 +++- cmd/run.go | 325 ++++++++++----- cmd/script.go | 55 --- cmd/servicecfg.go | 151 ------- cmd/spec.go | 87 ++++ cmd/targets.go | 374 ------------------ cmd/targets_test.go | 267 ------------- cmd/testdata/hello.lua | 4 - cmd/tls.go | 88 ++--- cmd/version.go | 23 +- docker-compose.yml | 19 + docker/docker-compose.yml | 14 - docs/capture.md | 55 +++ docs/docfs.go | 57 +++ docs/flags.md | 48 +++ docs/handlers.md | 74 ++++ docs/lua-api.md | 89 +++++ docs/quickstart.md | 47 +++ docs/tls.md | 37 ++ examples/gonetsim-listeners.toml | 69 ---- examples/handlers/ftp.lua | 64 --- go.mod | 47 ++- go.sum | 98 +++-- {examples/handlers => handlers}/irc.lua | 15 +- {examples/handlers => handlers}/smtp.lua | 57 ++- internal/app/rungroup.go | 64 +++ internal/capture/inspect.go | 100 ----- internal/capture/session_test.go | 349 ---------------- internal/config/config.go | 342 ---------------- internal/config/config_test.go | 216 ---------- internal/config/default_config.toml | 118 ------ internal/config/size.go | 41 -- internal/config/size_test.go | 31 -- internal/dnsserver/capture_test.go | 79 ---- internal/dnsserver/config.go | 70 ---- internal/dnsserver/dns_test.go | 314 --------------- internal/dnsserver/server.go | 218 ---------- internal/handler/echo.go | 27 -- internal/handler/handler_test.go | 151 ------- internal/handler/luapack.go | 326 --------------- internal/handler/sink.go | 23 -- internal/handler/testdata/bad_syntax.lua | 1 - internal/handler/testdata/comment.lua | 5 - internal/handler/testdata/isolated.lua | 9 - internal/handler/testdata/line_echo.lua | 8 - internal/handler/testdata/no_entry.lua | 2 - internal/handler/testdata/packet.lua | 8 - internal/handler/testdata/read_n.lua | 5 - internal/handler/testdata/read_until.lua | 5 - internal/handler/testdata/sandbox_escape.lua | 6 - internal/handler/testdata/sandbox_report.lua | 4 - internal/handler/testdata/sleep.lua | 9 - internal/handler/testdata/sleep_cap.lua | 5 - internal/handler/testdata/sni.lua | 9 - internal/handler/testdata/state.lua | 12 - internal/httpserver/capture_test.go | 57 --- internal/httpserver/config.go | 70 ---- internal/httpserver/content/index.css | 15 - internal/httpserver/content/index.html | 49 --- internal/httpserver/content/index.js | 18 - internal/httpserver/content/index.json | 23 -- internal/httpserver/content/index.txt | 15 - internal/httpserver/content/index.xml | 22 -- internal/httpserver/fakemode.go | 193 --------- internal/httpserver/http_test.go | 312 --------------- internal/httpserver/realmode.go | 116 ------ internal/httpserver/server.go | 128 ------ internal/listener/config.go | 48 --- internal/listener/listener_test.go | 351 ---------------- internal/listener/service.go | 33 -- internal/listener/tcp.go | 192 --------- internal/listener/udp.go | 99 ----- internal/logging/logger.go | 66 ++++ .../{service/logger.go => logging/prefix.go} | 15 +- internal/network/addrs.go | 20 + internal/network/close.go | 34 ++ internal/network/tcp.go | 166 ++++++++ internal/network/udp.go | 77 ++++ internal/netx/netx.go | 107 ----- internal/observability/logging.go | 54 --- .../{capture/recorder.go => pcap/conn.go} | 73 ++-- internal/{capture => pcap}/run.go | 103 +++-- internal/{capture => pcap}/session.go | 89 +++-- internal/script/api_capture.go | 18 + .../luabindings.go => script/api_conn.go} | 104 +---- internal/script/api_dns.go | 14 + internal/script/api_http.go | 16 + internal/script/api_log.go | 43 ++ internal/script/api_state.go | 43 ++ internal/script/builtin_echo.go | 29 ++ internal/script/builtin_sink.go | 24 ++ .../{handler/luaconn.go => script/conn.go} | 32 +- internal/{handler/lua.go => script/engine.go} | 40 +- internal/{handler => script}/handler.go | 39 +- internal/script/sandbox.go | 24 ++ internal/service/manager.go | 130 ------ internal/service/service.go | 16 - internal/state/state_test.go | 87 ---- internal/{state/state.go => store/kv.go} | 9 +- internal/testutil/testutil.go | 175 -------- .../config.go => tlscert/cert.go} | 27 +- .../{tlsprovider => tlscert}/selfsigned.go | 7 +- internal/tlsprovider/tls_test.go | 151 ------- main.go | 21 - 118 files changed, 2024 insertions(+), 7312 deletions(-) create mode 100644 .golangci.yaml delete mode 100644 CITATION.cff delete mode 100644 cmd/check.go delete mode 100644 cmd/license.go delete mode 100644 cmd/pcap.go delete mode 100644 cmd/pcap_test.go delete mode 100644 cmd/script.go delete mode 100644 cmd/servicecfg.go create mode 100644 cmd/spec.go delete mode 100644 cmd/targets.go delete mode 100644 cmd/targets_test.go delete mode 100644 cmd/testdata/hello.lua create mode 100644 docker-compose.yml delete mode 100644 docker/docker-compose.yml create mode 100644 docs/capture.md create mode 100644 docs/docfs.go create mode 100644 docs/flags.md create mode 100644 docs/handlers.md create mode 100644 docs/lua-api.md create mode 100644 docs/quickstart.md create mode 100644 docs/tls.md delete mode 100644 examples/gonetsim-listeners.toml delete mode 100644 examples/handlers/ftp.lua rename {examples/handlers => handlers}/irc.lua (86%) rename {examples/handlers => handlers}/smtp.lua (80%) create mode 100644 internal/app/rungroup.go delete mode 100644 internal/capture/inspect.go delete mode 100644 internal/capture/session_test.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/config/default_config.toml delete mode 100644 internal/config/size.go delete mode 100644 internal/config/size_test.go delete mode 100644 internal/dnsserver/capture_test.go delete mode 100644 internal/dnsserver/config.go delete mode 100644 internal/dnsserver/dns_test.go delete mode 100644 internal/dnsserver/server.go delete mode 100644 internal/handler/echo.go delete mode 100644 internal/handler/handler_test.go delete mode 100644 internal/handler/luapack.go delete mode 100644 internal/handler/sink.go delete mode 100644 internal/handler/testdata/bad_syntax.lua delete mode 100644 internal/handler/testdata/comment.lua delete mode 100644 internal/handler/testdata/isolated.lua delete mode 100644 internal/handler/testdata/line_echo.lua delete mode 100644 internal/handler/testdata/no_entry.lua delete mode 100644 internal/handler/testdata/packet.lua delete mode 100644 internal/handler/testdata/read_n.lua delete mode 100644 internal/handler/testdata/read_until.lua delete mode 100644 internal/handler/testdata/sandbox_escape.lua delete mode 100644 internal/handler/testdata/sandbox_report.lua delete mode 100644 internal/handler/testdata/sleep.lua delete mode 100644 internal/handler/testdata/sleep_cap.lua delete mode 100644 internal/handler/testdata/sni.lua delete mode 100644 internal/handler/testdata/state.lua delete mode 100644 internal/httpserver/capture_test.go delete mode 100644 internal/httpserver/config.go delete mode 100644 internal/httpserver/content/index.css delete mode 100644 internal/httpserver/content/index.html delete mode 100644 internal/httpserver/content/index.js delete mode 100644 internal/httpserver/content/index.json delete mode 100644 internal/httpserver/content/index.txt delete mode 100644 internal/httpserver/content/index.xml delete mode 100644 internal/httpserver/fakemode.go delete mode 100644 internal/httpserver/http_test.go delete mode 100644 internal/httpserver/realmode.go delete mode 100644 internal/httpserver/server.go delete mode 100644 internal/listener/config.go delete mode 100644 internal/listener/listener_test.go delete mode 100644 internal/listener/service.go delete mode 100644 internal/listener/tcp.go delete mode 100644 internal/listener/udp.go create mode 100644 internal/logging/logger.go rename internal/{service/logger.go => logging/prefix.go} (68%) create mode 100644 internal/network/addrs.go create mode 100644 internal/network/close.go create mode 100644 internal/network/tcp.go create mode 100644 internal/network/udp.go delete mode 100644 internal/netx/netx.go delete mode 100644 internal/observability/logging.go rename internal/{capture/recorder.go => pcap/conn.go} (80%) rename internal/{capture => pcap}/run.go (59%) rename internal/{capture => pcap}/session.go (74%) create mode 100644 internal/script/api_capture.go rename internal/{handler/luabindings.go => script/api_conn.go} (56%) create mode 100644 internal/script/api_dns.go create mode 100644 internal/script/api_http.go create mode 100644 internal/script/api_log.go create mode 100644 internal/script/api_state.go create mode 100644 internal/script/builtin_echo.go create mode 100644 internal/script/builtin_sink.go rename internal/{handler/luaconn.go => script/conn.go} (76%) rename internal/{handler/lua.go => script/engine.go} (72%) rename internal/{handler => script}/handler.go (56%) create mode 100644 internal/script/sandbox.go delete mode 100644 internal/service/manager.go delete mode 100644 internal/service/service.go delete mode 100644 internal/state/state_test.go rename internal/{state/state.go => store/kv.go} (80%) delete mode 100644 internal/testutil/testutil.go rename internal/{tlsprovider/config.go => tlscert/cert.go} (85%) rename internal/{tlsprovider => tlscert}/selfsigned.go (89%) delete mode 100644 internal/tlsprovider/tls_test.go delete mode 100644 main.go 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 ยป -
-

-

- - [![GitHub Repo stars](https://img.shields.io/github/stars/lachlanharrisdev/gonetsim?style=social)](https://github.com/lachlanharrisdev/gonetsim/stargazers) - [![GitHub](https://img.shields.io/github/license/lachlanharrisdev/gonetsim)](https://github.com/lachlanharrisdev/gonetsim?tab=Apache-2.0-1-ov-file) - [![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/lachlanharrisdev/gonetsim)](https://github.com/lachlanharrisdev/gonetsim/)
- [![GitHub CI Status](https://img.shields.io/github/actions/workflow/status/lachlanharrisdev/gonetsim/ci.yaml?branch=main&label=CI)](https://github.com/lachlanharrisdev/gonetsim/actions) - [![GitHub Release Status](https://img.shields.io/github/v/release/lachlanharrisdev/gonetsim)](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.

-
- Contribute - · - Report a Bug - · - Request a Feature -
-
- -
- -## 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 -``` + [![GitHub License](https://img.shields.io/github/license/lachlanharrisdev/gonetsim)](https://github.com/lachlanharrisdev/gonetsim?tab=Apache-2.0-1-ov-file) + [![CI Status](https://img.shields.io/github/actions/workflow/status/lachlanharrisdev/gonetsim/ci.yaml?branch=main&label=CI)](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. - -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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. - -[![Open in Dev Containers](https://img.shields.io/badge/Open%20In%20Dev%20Container-0078D4?style=for-the-badge&logo=visual%20studio%20code&logoColor=white)](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.

-
-

Documentation · - Repository · - Issues -

-
-

Copyright (c) Lachlan Harris 2026. All Rights Reserved.

-
- - \ No newline at end of file diff --git a/internal/httpserver/content/index.js b/internal/httpserver/content/index.js deleted file mode 100644 index ed10d3c..0000000 --- a/internal/httpserver/content/index.js +++ /dev/null @@ -1,18 +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. - -function Hello(name) { - const message = "Hello, " + name + "!"; - console.log(message); - return message; -} - -Hello("malware"); \ No newline at end of file diff --git a/internal/httpserver/content/index.json b/internal/httpserver/content/index.json deleted file mode 100644 index 7cfcd0f..0000000 --- a/internal/httpserver/content/index.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "title": "GoNetSim", - "description": "If you're seeing this file, the GoNetSim HTTP Server is running successfully in fake mode", - "links": [ - { - "id": 1, - "name": "Repository", - "link": "https://github.com/lachlanharrisdev/gonetsim" - }, - { - "id": 2, - "name": "HTTP Reference", - "link": "https://gonetsim.lachlanharris.dev/reference/http" - }, - { - "id": 3, - "name": "HTTPS Reference", - "link": "https://gonetsim.lachlanharris.dev/reference/https" - } - ], - "license": "Apache-2.0", - "copyright": "Copyright (c) 2026 Lachlan Harris. All Rights Reserved." -} diff --git a/internal/httpserver/content/index.txt b/internal/httpserver/content/index.txt deleted file mode 100644 index 51d2537..0000000 --- a/internal/httpserver/content/index.txt +++ /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 ------ - -* HTTP server reference: https://gonetsim.lachlanharris.dev/reference/http -* HTTPS server reference: https://gonetsim.lachlanharris.dev/reference/http -* GoNetSim repository: https://github.com/lachlanharrisdev/gonetsim - - -=========== -Copyright (c) Lachlan Harris 2026. All Rights Reserved. \ No newline at end of file diff --git a/internal/httpserver/content/index.xml b/internal/httpserver/content/index.xml deleted file mode 100644 index a56be53..0000000 --- a/internal/httpserver/content/index.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - GoNetSim - If you're seeing this file, the GoNetSim HTTP Server is running successfully in fake mode - - 1 - Repository - https://github.com/lachlanharrisdev/gonetsim - - - 2 - HTTP Reference - https://gonetsim.lachlanharris.dev/reference/http - - - 3 - HTTPS Reference - https://gonetsim.lachlanharris.dev/reference/https - - Apache-2.0 - Copyright (c) 2026 Lachlan Harris. All Rights Reserved. - diff --git a/internal/httpserver/fakemode.go b/internal/httpserver/fakemode.go deleted file mode 100644 index 1c7e9c1..0000000 --- a/internal/httpserver/fakemode.go +++ /dev/null @@ -1,193 +0,0 @@ -package httpserver - -import ( - "bytes" - "crypto/sha256" - _ "embed" - "log/slog" - "mime" - "net/http" - "path" - "strings" - "time" -) - -//go:embed content/index.html -var defaultIndexHTML []byte - -//go:embed content/index.txt -var defaultIndexTXT []byte - -//go:embed content/index.json -var defaultIndexJSON []byte - -//go:embed content/index.xml -var defaultIndexXML []byte - -//go:embed content/index.css -var defaultIndexCSS []byte - -//go:embed content/index.js -var defaultIndexJS []byte - -type FakeHandler struct { - // if non-zero, forces this status code for all responses. - StatusCode int - Logger *slog.Logger -} - -type fakeMeta struct { - cleanPath string - name string - ext string -} - -type fakeResponse struct { - contentType string - modTime time.Time - body []byte -} - -type fakeGenerator func(r *http.Request, m fakeMeta) fakeResponse - -type statusOverrideWriter struct { - http.ResponseWriter - status int - wroteHeader bool -} - -func (w *statusOverrideWriter) WriteHeader(code int) { - if w.wroteHeader { - return - } - w.wroteHeader = true - if w.status != 0 && code == http.StatusOK { - w.ResponseWriter.WriteHeader(w.status) - return - } - w.ResponseWriter.WriteHeader(code) -} - -type statusCaptureWriter struct { - http.ResponseWriter - status int -} - -func (w *statusCaptureWriter) WriteHeader(code int) { - w.status = code - w.ResponseWriter.WriteHeader(code) -} - -func (h FakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - m := resolveFakeMeta(r.URL.Path) - gen := defaultFakeRegistry.lookup(m.ext) - resp := gen(r, m) - - if resp.contentType != "" { - w.Header().Set("Content-Type", resp.contentType) - } - - serveContent(w, r, m.name, resp.modTime, bytes.NewReader(resp.body), h.StatusCode, h.Logger, r.ContentLength) -} - -func resolveFakeMeta(urlPath string) fakeMeta { - p := urlPath - if p == "" { - p = "/" - } - isDir := strings.HasSuffix(p, "/") - clean := path.Clean("/" + p) - name := path.Base(clean) - ext := strings.ToLower(path.Ext(name)) - if isDir || ext == "" { - ext = ".html" - name = "index.html" - } - return fakeMeta{cleanPath: clean, name: name, ext: ext} -} - -type fakeRegistry struct { - byExt map[string]fakeGenerator - defaultGen fakeGenerator -} - -func (r *fakeRegistry) lookup(ext string) fakeGenerator { - if r == nil { - return defaultFakeRegistry.defaultGen - } - if g, ok := r.byExt[ext]; ok { - return g - } - return r.defaultGen -} - -var defaultFakeRegistry = newDefaultFakeRegistry() - -func newDefaultFakeRegistry() *fakeRegistry { - mod := time.Unix(0, 0).UTC() - - htmlGen := func(_ *http.Request, _ fakeMeta) fakeResponse { - return fakeResponse{contentType: "text/html; charset=utf-8", modTime: mod, body: defaultIndexHTML} - } - - txtGen := func(_ *http.Request, m fakeMeta) fakeResponse { - return fakeResponse{contentType: "text/plain; charset=utf-8", modTime: mod, body: defaultIndexTXT} - } - - jsonGen := func(_ *http.Request, m fakeMeta) fakeResponse { - return fakeResponse{contentType: "application/json; charset=utf-8", modTime: mod, body: defaultIndexJSON} - } - - xmlGen := func(_ *http.Request, m fakeMeta) fakeResponse { - return fakeResponse{contentType: "application/xml; charset=utf-8", modTime: mod, body: defaultIndexXML} - } - - cssGen := func(_ *http.Request, m fakeMeta) fakeResponse { - return fakeResponse{contentType: "text/css; charset=utf-8", modTime: mod, body: defaultIndexCSS} - } - - jsGen := func(_ *http.Request, m fakeMeta) fakeResponse { - return fakeResponse{contentType: "application/javascript; charset=utf-8", modTime: mod, body: defaultIndexJS} - } - - unknownGen := func(_ *http.Request, m fakeMeta) fakeResponse { - sum := sha256.Sum256([]byte(m.cleanPath + "|" + m.ext)) - body := make([]byte, 512) - for i := 0; i < len(body); i++ { - body[i] = sum[i%len(sum)] - } - - ct := mime.TypeByExtension(m.ext) - if ct == "" { - ct = "application/octet-stream" - } - return fakeResponse{contentType: ct, modTime: mod, body: body} - } - - byExt := map[string]fakeGenerator{ - // default / directory - ".html": htmlGen, - ".htm": htmlGen, - - // common dynamic-page extensions (fake) - ".php": htmlGen, - ".asp": htmlGen, - ".aspx": htmlGen, - ".jsp": htmlGen, - - // text-ish - ".txt": txtGen, - ".log": txtGen, - ".md": txtGen, - - // structured - ".json": jsonGen, - ".xml": xmlGen, - - // web - ".css": cssGen, - ".js": jsGen, - } - - return &fakeRegistry{byExt: byExt, defaultGen: unknownGen} -} diff --git a/internal/httpserver/http_test.go b/internal/httpserver/http_test.go deleted file mode 100644 index 3f1fe44..0000000 --- a/internal/httpserver/http_test.go +++ /dev/null @@ -1,312 +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" - "crypto/tls" - "io" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/lachlanharrisdev/gonetsim/internal/testutil" - "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider" -) - -// / -// / "smoke" test for http server. starts server, makes a request, inspects response & shuts down server -// / -func TestHTTPServer_Smoke(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - // failed to listen on a local port with error - t.Fatalf("listen: %v", err) - } - - logger := testutil.Logger() - srv, err := NewServer(Config{Addr: "127.0.0.1:0", StatusCode: http.StatusCreated}, nil, logger) - if err != nil { - // failed to create server with error - t.Fatalf("New: %v", err) - } - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - _ = srv.Shutdown(ctx) - }() - - errCh := make(chan error, 1) - go func() { errCh <- srv.Serve(ln) }() - - url := "http://" + ln.Addr().String() + "/hello" - resp := mustGet(t, http.DefaultClient, url) - - defer func() { - if err := resp.Body.Close(); err != nil { - // failed to close response body with error - t.Fatalf("close: %v", err) - } - }() - - if resp.StatusCode != http.StatusCreated { - // failed to get expected status code - t.Fatalf("expected status %d, got %d", http.StatusCreated, resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - // failed to get expected content type - t.Fatalf("expected Content-Type text/html, got %q", ct) - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - - if !strings.Contains(string(body), "GoNetSim HTTP Server") { - // failed to get expected body content - t.Fatalf("expected response body to contain HTML page content, got %q", string(body)) - } - - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - _ = srv.Shutdown(ctx) - select { - case <-errCh: - case <-time.After(1 * time.Second): - // failed to shut down server cleanly - t.Fatalf("server did not exit") - } -} - -// / -// / same smoke test but for the https server. starts server with self-signed cert, makes a request, inspects response & shuts down server -// / -func TestHTTPSServer_Smoke(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - // failed to listen on a local port with error - t.Fatalf("listen: %v", err) - } - - cert, err := tlsprovider.GenerateSelfSigned(tlsprovider.SelfSignedOptions{DNSNames: []string{"localhost"}}) - if err != nil { - // failed to generate self-signed certificate with error - t.Fatalf("GenerateSelfSigned: %v", err) - } - - logger := testutil.Logger() - srv, err := NewServer(Config{Addr: "127.0.0.1:0", StatusCode: http.StatusOK}, nil, logger) - if err != nil { - // failed to create https server with error - t.Fatalf("New: %v", err) - } - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - _ = srv.Shutdown(ctx) - }() - - srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} - - errCh := make(chan error, 1) - go func() { - // pass in-memory certs w/o temp files - errCh <- srv.ServeTLS(ln, "", "") - }() - - client := &http.Client{ - Timeout: 2 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - url := "https://localhost:" + portFromAddr(t, ln.Addr().String()) + "/secure" - resp := mustGet(t, client, url) - defer func() { - if err := resp.Body.Close(); err != nil { - // failed to close response body with error - t.Fatalf("close: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - // failed to get expected status code from https server - t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("expected Content-Type text/html, got %q", ct) - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if !strings.Contains(string(body), "fake mode") { - t.Fatalf("expected response body to contain fake mode content, got %q", string(body)) - } - - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - _ = srv.Shutdown(ctx) - select { - case <-errCh: - case <-time.After(1 * time.Second): - // failed to shut down https server cleanly - t.Fatalf("https server did not exit") - } -} - -func mustGet(t *testing.T, client *http.Client, url string) *http.Response { - t.Helper() - _, resp := testutil.RetryGet(t, client, url) - return resp -} - -func portFromAddr(t *testing.T, addr string) string { - t.Helper() - return testutil.MustPort(t, addr) -} - -// tempDirWithFiles creates a temporary directory, writes the given files into it, -// and returns the directory path. Callers must defer os.RemoveAll on the result. -func tempDirWithFiles(t *testing.T, files map[string]string) string { - t.Helper() - dir := t.TempDir() - for name, content := range files { - // support subdirectories in file names - full := filepath.Join(dir, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(full, []byte(content), 0o644); err != nil { - t.Fatalf("WriteFile %s: %v", name, err) - } - } - return dir -} - -// startRealServer spins up a real-mode HTTP server on a random port, -// returns the server and its base URL. Caller must defer shutdown. -func startRealServer(t *testing.T, rootDir string, statusCode int) (*http.Server, string) { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - - logger := testutil.Logger() - srv, err := NewServer(Config{ - Addr: "127.0.0.1:0", - Mode: "real", - RootDir: rootDir, - StatusCode: statusCode, - }, nil, logger) - if err != nil { - t.Fatalf("NewServer: %v", err) - } - - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - _ = srv.Shutdown(ctx) - }) - - go func() { _ = srv.Serve(ln) }() - - return srv, "http://" + ln.Addr().String() -} - -// --- happy-path tests --- - -func TestRealHandler_ServesHTMLFile(t *testing.T) { - dir := tempDirWithFiles(t, map[string]string{ - "index.html": "hello world", - }) - _, base := startRealServer(t, dir, 0) - - resp := mustGet(t, http.DefaultClient, base+"/index.html") - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("expected text/html Content-Type, got %q", ct) - } - body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), "hello world") { - t.Fatalf("expected body to contain 'hello world', got %q", string(body)) - } -} - -func TestRealHandler_StatusCodeOverride(t *testing.T) { - dir := tempDirWithFiles(t, map[string]string{ - "page.html": "ok", - }) - _, base := startRealServer(t, dir, http.StatusCreated) - - resp := mustGet(t, http.DefaultClient, base+"/page.html") - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusCreated { - t.Fatalf("expected %d, got %d", http.StatusCreated, resp.StatusCode) - } -} - -// --- not-found / error tests --- - -func TestRealHandler_MissingFileReturns404(t *testing.T) { - dir := t.TempDir() // empty - _, base := startRealServer(t, dir, 0) - - resp := mustGet(t, http.DefaultClient, base+"/nonexistent.html") - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404, got %d", resp.StatusCode) - } -} - -// --- security tests --- - -func TestRealHandler_TraversalBlocked(t *testing.T) { - // Single server; sentinel file lives outside the root. - parent := t.TempDir() - secret := filepath.Join(parent, "secret.txt") - if err := os.WriteFile(secret, []byte("secret contents"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - root := filepath.Join(parent, "www") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - _, base := startRealServer(t, root, 0) - - for _, path := range []string{"/../secret.txt", "/%2e%2e/secret.txt", "/a/../secret.txt", "/../../secret.txt"} { - resp := mustGet(t, http.DefaultClient, base+path) - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - // Mmst not serve the file, 404 or 400 are both accepted - if resp.StatusCode == http.StatusOK { - t.Fatalf("traversal %q succeeded โ€” got 200 with body: %q", path, string(body)) - } - } -} - -// --- config validation tests --- - -func TestNewServer_RealMode_MissingRootDirReturnsError(t *testing.T) { - logger := testutil.Logger() - _, err := NewServer(Config{ - Addr: "127.0.0.1:0", - Mode: "real", - // RootDir intentionally omitted - }, nil, logger) - if err == nil { - t.Fatal("expected error when RootDir is empty, got nil") - } -} diff --git a/internal/httpserver/realmode.go b/internal/httpserver/realmode.go deleted file mode 100644 index d4f3e33..0000000 --- a/internal/httpserver/realmode.go +++ /dev/null @@ -1,116 +0,0 @@ -package httpserver - -import ( - "log/slog" - "net/http" - "os" - "path/filepath" - "strings" -) - -type RealHandler struct { - // same statuscode implementation as fake mode - StatusCode int - RootDir string - Logger *slog.Logger -} - -func (h RealHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - logger := h.Logger - - reqPath := strings.TrimPrefix(r.URL.Path, "/") - if reqPath == "" { - reqPath = "index.html" - } - - target := filepath.Join(h.RootDir, filepath.FromSlash(reqPath)) - - absRoot, err := filepath.Abs(h.RootDir) - if err != nil { - logger.Error("could not resolve root directory", "root", h.RootDir, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - absTarget, err := filepath.Abs(target) - if err != nil { - logger.Error("could not resolve requested path", "path", r.URL.Path, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if !pathWithin(absRoot, absTarget) { - logger.Warn("directory traversal blocked", "src", r.RemoteAddr, "path", r.URL.Path) - http.NotFound(w, r) - return - } - - f, err := os.Open(absTarget) - if err != nil { - if os.IsNotExist(err) { - logger.Debug("file not found", "path", r.URL.Path) - http.NotFound(w, r) - } else { - logger.Error("error opening file", "path", r.URL.Path, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return - } - - stat, err := f.Stat() - if err != nil { - _ = f.Close() - logger.Error("error statting file", "path", r.URL.Path, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // Directories are served via their index.html when one exists. - if stat.IsDir() { - _ = f.Close() - index := filepath.Join(absTarget, "index.html") - f, err = os.Open(index) - if err != nil { - if os.IsNotExist(err) { - logger.Debug("directory listing blocked", "path", r.URL.Path) - } else { - logger.Error("error opening directory index", "path", r.URL.Path, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - http.NotFound(w, r) - return - } - stat, err = f.Stat() - if err != nil { - _ = f.Close() - logger.Error("error statting directory index", "path", r.URL.Path, "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if stat.IsDir() { - _ = f.Close() - logger.Debug("directory index is a directory", "path", r.URL.Path) - http.NotFound(w, r) - return - } - } - defer f.Close() //nolint:errcheck - - serveContent(w, r, stat.Name(), stat.ModTime(), f, h.StatusCode, logger, stat.Size()) -} - -// pathWithin reports whether child is inside parent (or equals it), using only -// lexical path components. Both arguments must already be absolute. -func pathWithin(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - if err != nil { - return false - } - if rel == "." { - return true - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return false - } - return !filepath.IsAbs(rel) -} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go deleted file mode 100644 index 4c47729..0000000 --- a/internal/httpserver/server.go +++ /dev/null @@ -1,128 +0,0 @@ -package httpserver - -import ( - "context" - "crypto/tls" - "errors" - "io" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/netx" - "github.com/lachlanharrisdev/gonetsim/internal/service" -) - -type Server struct { - name string - conf Config - srv *http.Server - log *slog.Logger - run *capture.Run -} - -func NewService(conf Config, logger *slog.Logger, run *capture.Run) service.Service { - name := "HTTP" - if conf.TLS != nil { - name = "HTTPS" - } - if !conf.Capture { - run = nil - } - - return &Server{name: name, conf: conf.normalize(), log: service.NewPrefixedLogger(logger, name), run: run} -} - -func (s *Server) Name() string { - return s.name -} - -func NewServer(conf Config, handler http.Handler, logger *slog.Logger) (*http.Server, error) { - if err := conf.Validate(); err != nil { - return nil, err - } - conf = conf.normalize() - - if handler == nil { - if conf.Mode == "real" { - handler = RealHandler{StatusCode: conf.StatusCode, RootDir: conf.RootDir, Logger: logger} - } else { - handler = FakeHandler{StatusCode: conf.StatusCode, Logger: logger} - } - } - - srv := &http.Server{ - Addr: conf.Addr, - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, - IdleTimeout: 60 * time.Second, - } - return srv, nil -} - -func (s *Server) Start(ctx context.Context) error { - logger := s.log - - srv, err := NewServer(s.conf, nil, logger) - if err != nil { - return err - } - s.srv = srv - - var tlsConf *tls.Config - if s.conf.TLS != nil { - tlsConf, err = s.conf.TLS.TLSConfig() - if err != nil { - return err - } - srv.TLSConfig = tlsConf - } - iface, err := s.run.NewInterface("gonetsim " + strings.ToLower(s.name) + " tcp") - if err != nil { - return err - } - ln, err := netx.ListenTCP(s.conf.Addr, s.run, iface, tlsConf) - if err != nil { - return err - } - defer func() { _ = ln.Close() }() - - logger.Info("listening", "on", s.conf.Addr, "mode", s.conf.Mode) - if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -func (s *Server) Stop(ctx context.Context) error { - if s.srv != nil { - return s.srv.Shutdown(ctx) - } - return nil -} - -func serveContent(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, content io.ReadSeeker, statusOverride int, logger *slog.Logger, contentLen any) { - cap := &statusCaptureWriter{ResponseWriter: w} - out := http.ResponseWriter(cap) - if statusOverride != 0 { - out = &statusOverrideWriter{ResponseWriter: cap, status: statusOverride} - } - - http.ServeContent(out, r, name, modTime, content) - - status := cap.status - if status == 0 { - status = http.StatusOK - } - logger.Info( - r.Method, - "src", r.RemoteAddr, - "to", r.URL.Path, - "status", status, - "host", r.Host, - "ua", r.UserAgent(), - "len", contentLen, - ) -} diff --git a/internal/listener/config.go b/internal/listener/config.go deleted file mode 100644 index fcb63a9..0000000 --- a/internal/listener/config.go +++ /dev/null @@ -1,48 +0,0 @@ -package listener - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/lachlanharrisdev/gonetsim/internal/netx" - "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider" -) - -type Config struct { - Name string - Network string // "tcp" or "udp" - Addr string - HandlerSpec string - ReadTimeout time.Duration // idle timeout applied to each connection - TLS *tlsprovider.Config - Capture bool - // BaseDir is the directory relative handler script paths resolve against. - BaseDir string -} - -func (c Config) Validate() error { - if strings.TrimSpace(c.Name) == "" { - return errors.New("name is required") - } - if err := netx.ValidateNetwork(c.Network, "tcp", "udp"); err != nil { - return err - } - if c.Addr == "" { - return errors.New("listen addr is required") - } - if _, err := netx.ParseAddr(c.Addr); err != nil { - return fmt.Errorf("invalid listen addr %q (expected host:port): %w", c.Addr, err) - } - if strings.TrimSpace(c.HandlerSpec) == "" { - return errors.New("handler is required") - } - if c.ReadTimeout <= 0 { - return errors.New("read_timeout must be > 0") - } - if c.TLS != nil && c.Network != "tcp" { - return errors.New("tls is only supported on tcp listeners") - } - return nil -} diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go deleted file mode 100644 index 13501ab..0000000 --- a/internal/listener/listener_test.go +++ /dev/null @@ -1,351 +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 listener - -import ( - "context" - "io" - "log/slog" - "net" - "os" - "strings" - "testing" - "time" - - "github.com/google/gopacket" - "github.com/google/gopacket/layers" - "github.com/google/gopacket/pcapgo" - "github.com/lachlanharrisdev/gonetsim/internal/service" - "github.com/lachlanharrisdev/gonetsim/internal/testutil" - "github.com/lachlanharrisdev/gonetsim/internal/tlsprovider" -) - -func testLogger() *slog.Logger { - return testutil.Logger() -} - -func startService(t *testing.T, svc service.Service) { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - done <- svc.Start(ctx) - }() - t.Cleanup(func() { - cancel() - select { - case err := <-done: - if err != nil { - t.Errorf("service start error: %v", err) - } - case <-time.After(5 * time.Second): - t.Error("service did not stop within 5s") - } - }) -} - -func dialTCP(t *testing.T, addr string) net.Conn { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", addr, time.Second) - if err == nil { - return conn - } - lastErr = err - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("dial %s failed: %v", addr, lastErr) - return nil -} - -func echoConfig(t *testing.T) Config { - return Config{ - Name: "echotest", - Network: "tcp", - Addr: testutil.FreePort(t, "tcp"), - HandlerSpec: "builtin:echo", - ReadTimeout: 5 * time.Second, - Capture: true, - } -} - -// waitTransportFrames polls the capture until the transport payload sequence -// matches want, tolerating the async flush that follows connection teardown. -func waitTransportFrames(t *testing.T, path, proto string, want []string) { - t.Helper() - testutil.WaitFor(t, 3*time.Second, "payload sequence match", func() bool { - got, err := transportPayloads(path, proto) - return err == nil && strings.Join(got, "|") == strings.Join(want, "|") - }) -} - -// waitSubstringFrames polls until the concatenated payload sequence of a -// capture contains want (used where multiple datagrams share one writer). -func waitSubstringFrames(t *testing.T, path, proto, want string) { - t.Helper() - testutil.WaitFor(t, 3*time.Second, "payload substring match", func() bool { - got, err := transportPayloads(path, proto) - return err == nil && strings.Contains(strings.Join(got, "|"), want) - }) -} - -// transportPayloads extracts transport-layer payloads from a pcapng file, -// or an error if the file is empty or unreadable. -func transportPayloads(path, proto string) ([]string, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - r, err := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions) - if err != nil { - return nil, err - } - var out []string - for { - data, _, err := r.ReadPacketData() - if err != nil { - break - } - pkt := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default) - var payload []byte - if proto == "udp" { - if u, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP); ok { - payload = u.Payload - } - } else if t, ok := pkt.Layer(layers.LayerTypeTCP).(*layers.TCP); ok { - payload = t.Payload - } - out = append(out, string(payload)) - } - return out, nil -} - -func TestTCPService(t *testing.T) { - t.Run("echo over tcp with pcapng capture", func(t *testing.T) { - conf := echoConfig(t) - run, path := testutil.NewPcapRun(t) - - svc, err := NewService(conf, nil, testLogger(), run) - if err != nil { - t.Fatalf("NewService: %v", err) - } - startService(t, svc) - - conn := dialTCP(t, conf.Addr) - if _, err := conn.Write([]byte("hello\n")); err != nil { - t.Fatalf("Write: %v", err) - } - buf := make([]byte, 6) - if _, err := io.ReadFull(conn, buf); err != nil { - t.Fatalf("ReadFull: %v", err) - } - if string(buf) != "hello\n" { - t.Fatalf("expected echo, got %q", buf) - } - _ = conn.Close() - - waitTransportFrames(t, path, "tcp", - []string{"", "", "hello\n", "hello\n", "", ""}) - }) - - t.Run("idle timeout closes connection", func(t *testing.T) { - conf := echoConfig(t) - conf.ReadTimeout = 200 * time.Millisecond - - svc, err := NewService(conf, nil, testLogger(), nil) - if err != nil { - t.Fatalf("NewService: %v", err) - } - startService(t, svc) - - conn := dialTCP(t, conf.Addr) - defer func() { _ = conn.Close() }() - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - - buf := make([]byte, 1) - if _, err := conn.Read(buf); err == nil { - t.Fatalf("expected read to fail after idle timeout") - } - }) - - t.Run("script errors don't kill the listener", func(t *testing.T) { - conf := Config{ - Name: "isotest", - Network: "tcp", - Addr: testutil.FreePort(t, "tcp"), - HandlerSpec: "lua:isolated.lua", - BaseDir: "../handler/testdata", - ReadTimeout: 5 * time.Second, - } - svc, err := NewService(conf, nil, testLogger(), nil) - if err != nil { - t.Fatalf("NewService: %v", err) - } - startService(t, svc) - - conn := dialTCP(t, conf.Addr) - _, _ = conn.Write([]byte("boom\n")) - _ = conn.Close() - - conn = dialTCP(t, conf.Addr) - defer func() { _ = conn.Close() }() - if _, err := conn.Write([]byte("fine\n")); err != nil { - t.Fatalf("Write: %v", err) - } - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - buf := make([]byte, 3) - if _, err := io.ReadFull(conn, buf); err != nil { - t.Fatalf("ReadFull: %v", err) - } - if string(buf) != "ok\n" { - t.Fatalf("expected ok, got %q", buf) - } - }) -} - -func TestUDPCapture(t *testing.T) { - exchange := func(t *testing.T, addr, payload, want string) { - t.Helper() - server, err := net.ResolveUDPAddr("udp", addr) - if err != nil { - t.Fatalf("ResolveUDPAddr: %v", err) - } - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("ListenPacket: %v", err) - } - defer func() { _ = pc.Close() }() - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if _, err := pc.WriteTo([]byte(payload), server); err != nil { - t.Fatalf("WriteTo: %v", err) - } - _ = pc.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - buf := make([]byte, 64) - n, _, err := pc.ReadFrom(buf) - if err == nil { - if string(buf[:n]) != want { - t.Fatalf("expected %q, got %q", want, buf[:n]) - } - return - } - } - t.Fatalf("no reply for %q", payload) - } - - t.Run("udp echo produces pcapng", func(t *testing.T) { - conf := Config{ - Name: "udpecho", - Network: "udp", - Addr: testutil.FreePort(t, "udp"), - HandlerSpec: "builtin:echo", - ReadTimeout: 150 * time.Millisecond, - Capture: true, - } - run, path := testutil.NewPcapRun(t) - svc, err := NewService(conf, nil, testLogger(), run) - if err != nil { - t.Fatalf("NewService: %v", err) - } - startService(t, svc) - exchange(t, conf.Addr, "ping", "ping") - - waitSubstringFrames(t, path, "udp", "ping|ping") - }) - - t.Run("udp lua packets", func(t *testing.T) { - conf := Config{ - Name: "udplua", - Network: "udp", - Addr: testutil.FreePort(t, "udp"), - HandlerSpec: "lua:packet.lua", - BaseDir: "../handler/testdata", - ReadTimeout: 5 * time.Second, - } - svc, err := NewService(conf, nil, testLogger(), nil) - if err != nil { - t.Fatalf("NewService: %v", err) - } - startService(t, svc) - exchange(t, conf.Addr, "ping", "pong") - }) -} - -func TestStartWithCancelledContext(t *testing.T) { - for _, network := range []string{"tcp", "udp"} { - conf := Config{ - Name: "canceled-" + network, - Network: network, - Addr: testutil.FreePort(t, network), - HandlerSpec: "builtin:sink", - ReadTimeout: 5 * time.Second, - } - svc, err := NewService(conf, nil, testLogger(), nil) - if err != nil { - t.Fatalf("NewService: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - done := make(chan error, 1) - go func() { - done <- svc.Start(ctx) - }() - select { - case err := <-done: - if err != nil { - t.Fatalf("Start (%s): %v", network, err) - } - case <-time.After(5 * time.Second): - t.Fatalf("Start (%s) did not return after cancellation", network) - } - } -} - -func TestNewServiceValidation(t *testing.T) { - base := func() Config { - return Config{ - Name: "bad", - Network: "tcp", - Addr: "127.0.0.1:0", - HandlerSpec: "builtin:echo", - ReadTimeout: 5 * time.Second, - } - } - - cases := []struct { - name string - mutate func(*Config) - wantErr string - }{ - {"unknown network", func(c *Config) { c.Network = "sctp" }, "network must be"}, - {"bad addr", func(c *Config) { c.Addr = "nope" }, "invalid listen addr"}, - {"missing handler", func(c *Config) { c.HandlerSpec = "" }, "handler is required"}, - {"bad timeout", func(c *Config) { c.ReadTimeout = -1 }, "read_timeout must be"}, - {"tls on udp", func(c *Config) { - c.Network = "udp" - c.TLS = &tlsprovider.Config{} - }, "tls is only supported on tcp"}, - {"missing script", func(c *Config) { c.HandlerSpec = "lua:missing.lua" }, "read lua script"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - conf := base() - tc.mutate(&conf) - _, err := NewService(conf, nil, testLogger(), nil) - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) - } - }) - } -} diff --git a/internal/listener/service.go b/internal/listener/service.go deleted file mode 100644 index 0369ca3..0000000 --- a/internal/listener/service.go +++ /dev/null @@ -1,33 +0,0 @@ -package listener - -import ( - "fmt" - "log/slog" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/handler" - "github.com/lachlanharrisdev/gonetsim/internal/service" - "github.com/lachlanharrisdev/gonetsim/internal/state" -) - -func NewService(conf Config, global *state.Store, logger *slog.Logger, run *capture.Run) (service.Service, error) { - if global == nil { - global = state.NewStore(nil) - } - if err := conf.Validate(); err != nil { - return nil, fmt.Errorf("listener %s: %w", conf.Name, err) - } - h, err := handler.New(conf.HandlerSpec, conf.BaseDir, global.Budget()) - if err != nil { - return nil, fmt.Errorf("listener %s handler: %w", conf.Name, err) - } - - log := service.NewPrefixedLogger(logger, conf.Name) - if !conf.Capture { - run = nil - } - if conf.Network == "udp" { - return &udpService{conf: conf, handler: h, log: log, run: run, global: global}, nil - } - return &tcpService{conf: conf, handler: h, log: log, run: run, global: global}, nil -} diff --git a/internal/listener/tcp.go b/internal/listener/tcp.go deleted file mode 100644 index 274592a..0000000 --- a/internal/listener/tcp.go +++ /dev/null @@ -1,192 +0,0 @@ -package listener - -import ( - "context" - "crypto/tls" - "errors" - "log/slog" - "net" - "os" - "sync" - "time" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/handler" - "github.com/lachlanharrisdev/gonetsim/internal/netx" - "github.com/lachlanharrisdev/gonetsim/internal/state" -) - -type tcpService struct { - conf Config - handler handler.TCPHandler - log *slog.Logger - run *capture.Run - global *state.Store - - mu sync.Mutex - ln net.Listener - conns connSet - wg sync.WaitGroup -} - -func (s *tcpService) Name() string { return s.conf.Name } - -func (s *tcpService) Stop(_ context.Context) error { - s.mu.Lock() - ln := s.ln - s.mu.Unlock() - if ln != nil { - _ = ln.Close() - } - s.conns.closeAll() - return nil -} - -func (s *tcpService) Start(ctx context.Context) error { - var tlsConf *tls.Config - if s.conf.TLS != nil { - var err error - tlsConf, err = s.conf.TLS.TLSConfig() - if err != nil { - return err - } - } - iface, err := s.run.NewInterface("gonetsim " + s.conf.Name + " tcp") - if err != nil { - return err - } - ln, err := netx.ListenTCP(s.conf.Addr, s.run, iface, tlsConf) - if err != nil { - return err - } - defer func() { _ = ln.Close() }() - - s.mu.Lock() - s.ln = ln - s.mu.Unlock() - defer func() { - s.mu.Lock() - s.ln = nil - s.mu.Unlock() - }() - - done := netx.CloseOnCancel(ctx, ln) - defer done() - - s.log.Info("listening", "on", s.conf.Addr, "handler", s.conf.HandlerSpec) - if err := s.accept(ctx, ln); err != nil && !netx.IsExpectedClose(err, ctx) { - return err - } - - s.conns.closeAll() // unblock handlers still serving connections - s.wg.Wait() - return nil -} - -func (s *tcpService) accept(ctx context.Context, ln net.Listener) error { - for { - conn, err := ln.Accept() - if err != nil { - return err - } - s.conns.add(conn) - s.wg.Add(1) - go func() { - defer s.wg.Done() - s.handleConn(ctx, conn) - s.conns.remove(conn) - }() - } -} - -func (s *tcpService) handleConn(ctx context.Context, conn net.Conn) { - defer func() { _ = conn.Close() }() - - var env *capture.Session - if cc, ok := conn.(*capture.Conn); ok { - env = cc.Session() - } - conn = newIdleConn(conn, s.conf.ReadTimeout) - - henv := handler.Env{Logger: s.log, Capture: env, IdleTimeout: s.conf.ReadTimeout, Global: s.global} - err := s.handler.HandleTCP(ctx, conn, henv) - switch { - case err == nil, - errors.Is(err, net.ErrClosed), - errors.Is(err, os.ErrDeadlineExceeded), - errors.Is(err, context.Canceled): - s.log.Debug("connection closed", "remote", conn.RemoteAddr().String()) - default: - s.log.Info("connection handler error", "remote", conn.RemoteAddr().String(), "err", err) - } -} - -// idleConn pushes the deadline forward on every read/write, capping how long -// a handler blocks on a quiet connection. -type idleConn struct { - net.Conn - timeout time.Duration -} - -func newIdleConn(conn net.Conn, timeout time.Duration) idleConn { - return idleConn{Conn: conn, timeout: timeout} -} - -func (c idleConn) Read(p []byte) (int, error) { - _ = c.SetDeadline(time.Now().Add(c.timeout)) - return c.Conn.Read(p) -} - -func (c idleConn) Write(p []byte) (int, error) { - _ = c.SetDeadline(time.Now().Add(c.timeout)) - return c.Conn.Write(p) -} - -// ConnectionState/HandshakeContext forward TLS operations through the wrapper. -func (c idleConn) ConnectionState() tls.ConnectionState { - if tc, ok := c.Conn.(interface { - ConnectionState() tls.ConnectionState - HandshakeContext(ctx context.Context) error - }); ok { - return tc.ConnectionState() - } - return tls.ConnectionState{} -} - -func (c idleConn) HandshakeContext(ctx context.Context) error { - if tc, ok := c.Conn.(interface { - ConnectionState() tls.ConnectionState - HandshakeContext(ctx context.Context) error - }); ok { - return tc.HandshakeContext(ctx) - } - return nil -} - -type connSet struct { - mu sync.Mutex - conns map[net.Conn]struct{} -} - -func (cs *connSet) add(c net.Conn) { - cs.mu.Lock() - defer cs.mu.Unlock() - if cs.conns == nil { - cs.conns = make(map[net.Conn]struct{}) - } - cs.conns[c] = struct{}{} -} - -func (cs *connSet) remove(c net.Conn) { - cs.mu.Lock() - defer cs.mu.Unlock() - delete(cs.conns, c) -} - -func (cs *connSet) closeAll() { - cs.mu.Lock() - defer cs.mu.Unlock() - for c := range cs.conns { - _ = c.Close() - } -} diff --git a/internal/listener/udp.go b/internal/listener/udp.go deleted file mode 100644 index 26cbc45..0000000 --- a/internal/listener/udp.go +++ /dev/null @@ -1,99 +0,0 @@ -package listener - -import ( - "context" - "errors" - "log/slog" - "os" - "sync" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/handler" - "github.com/lachlanharrisdev/gonetsim/internal/netx" - "github.com/lachlanharrisdev/gonetsim/internal/state" -) - -const maxPacketSize = 65535 - -// udpService handles datagrams sequentially on one socket, so replies keep -// receive order and scripts never run concurrently. -type udpService struct { - conf Config - handler handler.UDPHandler - log *slog.Logger - run *capture.Run - global *state.Store - - mu sync.Mutex - pc *capture.PacketConn -} - -func (s *udpService) Name() string { return s.conf.Name } - -func (s *udpService) Stop(_ context.Context) error { - s.mu.Lock() - pc := s.pc - s.mu.Unlock() - if pc != nil { - _ = pc.Close() - pc.CloseAll() - } - return nil -} - -func (s *udpService) Start(ctx context.Context) error { - iface, err := s.run.NewInterface("gonetsim " + s.conf.Name + " udp") - if err != nil { - return err - } - rec, err := netx.ListenUDP(s.conf.Addr, s.run, iface, s.conf.ReadTimeout) - if err != nil { - return err - } - defer func() { _ = rec.Close() }() - - s.mu.Lock() - s.pc = rec - s.mu.Unlock() - defer func() { - s.mu.Lock() - s.pc = nil - s.mu.Unlock() - }() - - done := netx.CloseOnCancel(ctx, rec) - defer done() - - s.log.Info("listening", "on", s.conf.Addr, "handler", s.conf.HandlerSpec, "net", "udp") - if err := s.readLoop(ctx, rec); err != nil && !netx.IsExpectedClose(err, ctx) { - return err - } - rec.CloseAll() - return nil -} - -func (s *udpService) readLoop(ctx context.Context, pc *capture.PacketConn) error { - buf := make([]byte, maxPacketSize) - for { - n, remote, err := pc.ReadFrom(buf) - if err != nil { - return err - } - - data := make([]byte, n) - copy(data, buf[:n]) - - env := handler.Env{Logger: s.log, Capture: pc.SessionFor(remote), Global: s.global} - - reply, err := s.handler.HandleUDP(ctx, data, remote, env) - if err != nil { - s.log.Info("packet handler error", "remote", remote.String(), "err", err) - continue - } - if reply != nil { - if _, err := pc.WriteTo(reply, remote); err != nil && !errors.Is(err, os.ErrDeadlineExceeded) { - s.log.Debug("reply failed", "remote", remote.String(), "err", err) - } - } - } -} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..c606211 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,66 @@ +// Package logging builds the process-wide slog logger. Human output is +// message-only - no key=value fields, ever - and colourised on a TTY by charm; +// the same events are emitted machine-readable with --log-format json. +package logging + +import ( + "errors" + "log/slog" + "os" + "strings" + + "charm.land/lipgloss/v2" + "charm.land/log/v2" +) + +// New returns the root logger. level is one of debug, info, warn, error +// (default info); format is "text" (default) or "json". +func New(level, format string) (*slog.Logger, error) { + opts := log.Options{ + Level: log.Level(parseLevel(level)), + ReportTimestamp: true, + TimeFormat: "15:04:05", + } + switch strings.ToLower(strings.TrimSpace(format)) { + case "", "text": + lg := log.NewWithOptions(os.Stderr, opts) + lg.SetStyles(textStyles()) + return slog.New(lg), nil + case "json": + opts.Formatter = log.JSONFormatter + return slog.New(log.NewWithOptions(os.Stderr, opts)), nil + default: + return nil, errors.New("log format must be \"text\" or \"json\"") + } +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// textStyles uses three-letter level tags instead of the default full names so +// lines stay compact. Colours only ever render on a real terminal; charm +// strips ANSI when the output is piped or on a bare Windows console. +func textStyles() *log.Styles { + s := log.DefaultStyles() + s.Levels = map[log.Level]lipgloss.Style{ + log.DebugLevel: tagStyle("DBG", "63"), + log.InfoLevel: tagStyle("INF", "86"), + log.WarnLevel: tagStyle("WRN", "192"), + log.ErrorLevel: tagStyle("ERR", "204"), + } + return s +} + +func tagStyle(tag, color string) lipgloss.Style { + return lipgloss.NewStyle().SetString(tag).Bold(true).Width(3).Foreground(lipgloss.Color(color)) +} diff --git a/internal/service/logger.go b/internal/logging/prefix.go similarity index 68% rename from internal/service/logger.go rename to internal/logging/prefix.go index a297b75..395f736 100644 --- a/internal/service/logger.go +++ b/internal/logging/prefix.go @@ -1,13 +1,17 @@ -package service +package logging import ( "context" - "fmt" "log/slog" "strings" ) -// prefix handling system, so prefix for services is display whilst still being able to use lmittman/tint +// WithPrefix returns a logger whose records carry a [NAME] tag on the front of +// the message, so each listener's output lines up when several run at once. +func WithPrefix(root *slog.Logger, name string) *slog.Logger { + return slog.New(&prefixHandler{prefix: "[" + strings.ToUpper(name) + "] ", next: root.Handler()}) +} + type prefixHandler struct { prefix string next slog.Handler @@ -33,8 +37,3 @@ func (h *prefixHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (h *prefixHandler) WithGroup(name string) slog.Handler { return &prefixHandler{prefix: h.prefix, next: h.next.WithGroup(name)} } - -func NewPrefixedLogger(rootLogger *slog.Logger, serviceName string) *slog.Logger { - prefix := fmt.Sprintf("%-5s ", strings.ToUpper(serviceName)) - return slog.New(&prefixHandler{prefix: prefix, next: rootLogger.Handler()}) -} diff --git a/internal/network/addrs.go b/internal/network/addrs.go new file mode 100644 index 0000000..3a885b6 --- /dev/null +++ b/internal/network/addrs.go @@ -0,0 +1,20 @@ +package network + +import ( + "errors" + "fmt" + "net" +) + +var errListenAddrRequired = errors.New("listen address is required") + +// ParseAddr validates that addr is a usable TCP/UDP host:port. +func ParseAddr(addr string) (string, error) { + if addr == "" { + return "", errListenAddrRequired + } + if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { + return "", fmt.Errorf("invalid listen address %q (expected host:port): %w", addr, err) + } + return addr, nil +} diff --git a/internal/network/close.go b/internal/network/close.go new file mode 100644 index 0000000..6f227e5 --- /dev/null +++ b/internal/network/close.go @@ -0,0 +1,34 @@ +package network + +import ( + "context" + "errors" + "io" + "net" +) + +// closeOnCancel closes c when ctx is done. Call the returned stop func when +// the caller is shutting down anyway. +func closeOnCancel(ctx context.Context, c io.Closer) (stop func()) { + stopped := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = c.Close() + case <-stopped: + } + }() + return func() { close(stopped) } +} + +// expectedClose reports whether err is the normal outcome of a listener being +// closed (shutdown) rather than a real failure. +func expectedClose(err error, ctx context.Context) bool { + if err == nil { + return true + } + if errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) { + return true + } + return ctx.Err() != nil +} diff --git a/internal/network/tcp.go b/internal/network/tcp.go new file mode 100644 index 0000000..95a0ec2 --- /dev/null +++ b/internal/network/tcp.go @@ -0,0 +1,166 @@ +package network + +import ( + "context" + "crypto/tls" + "errors" + "log/slog" + "net" + "os" + "sync" + "time" + + "github.com/lachlanharrisdev/gonetsim/internal/pcap" +) + +// TCPHandler serves one accepted connection. conn is the fully-wrapped socket +// (capture recorder underneath, optional TLS above); ses is the connection's +// capture session, or nil when capture is disabled. Returning nil (or a +// deadline/close error) is a clean close. +type TCPHandler func(ctx context.Context, conn net.Conn, ses *pcap.Session) error + +// ServeTCP accepts connections on addr until ctx is cancelled or the listener +// fails. Each accepted connection is handled in its own goroutine. +func ServeTCP(ctx context.Context, name, addr string, tlsCfg *tls.Config, run *pcap.Run, idle time.Duration, log *slog.Logger, h TCPHandler) error { + iface, err := run.NewInterface("gonetsim " + name + " tcp") + if err != nil { + return err + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer func() { _ = ln.Close() }() + + stop := closeOnCancel(ctx, ln) + defer stop() + + msg := "listening on " + addr + if tlsCfg != nil { + msg += " (tls)" + } + log.Info(msg) + + var cs connSet = newConnSet() + var wg sync.WaitGroup + for { + conn, err := ln.Accept() + if err != nil { + cs.closeAll() // unblock handlers still serving connections + wg.Wait() + if expectedClose(err, ctx) { + return nil + } + return err + } + cs.add(conn) + wg.Add(1) + go func() { + defer wg.Done() + defer cs.remove(conn) + serveConn(ctx, conn, tlsCfg, run, iface, idle, log, h) + }() + } +} + +func serveConn(ctx context.Context, conn net.Conn, tlsCfg *tls.Config, run *pcap.Run, iface int, idle time.Duration, log *slog.Logger, h TCPHandler) { + var ses *pcap.Session + final := conn + if run != nil { + var rec net.Conn + rec, ses = pcap.NewConn(conn, run, iface) + if rec != conn { + final = rec + } + } + if tlsCfg != nil { + // TLS above the recorder: ciphertext is captured exactly as it hits + // the wire, and the session stays available for annotations. + final = tls.Server(final, tlsCfg) + } + final = idleConn{Conn: final, timeout: idle} + + // close the fully-wrapped conn so the recorder emits FIN/FIN-ACK. + defer func() { _ = final.Close() }() + + err := h(ctx, final, ses) + switch { + case err == nil, + errors.Is(err, net.ErrClosed), + errors.Is(err, os.ErrDeadlineExceeded), + errors.Is(err, context.Canceled): + log.Debug("connection from " + conn.RemoteAddr().String() + " closed") + default: + log.Info("connection from " + conn.RemoteAddr().String() + " failed: " + err.Error()) + } +} + +// idleConn pushes the deadline forward on every read/write, capping how long a +// handler blocks on a quiet connection. A zero timeout disables the deadline. +// TLS operations pass through it. +type idleConn struct { + net.Conn + timeout time.Duration +} + +func (c idleConn) Read(p []byte) (int, error) { + if c.timeout > 0 { + _ = c.SetDeadline(time.Now().Add(c.timeout)) + } + return c.Conn.Read(p) +} + +func (c idleConn) Write(p []byte) (int, error) { + if c.timeout > 0 { + _ = c.SetDeadline(time.Now().Add(c.timeout)) + } + return c.Conn.Write(p) +} + +type tlsIntros interface { + ConnectionState() tls.ConnectionState + HandshakeContext(ctx context.Context) error +} + +func (c idleConn) ConnectionState() tls.ConnectionState { + if tc, ok := c.Conn.(tlsIntros); ok { + return tc.ConnectionState() + } + return tls.ConnectionState{} +} + +func (c idleConn) HandshakeContext(ctx context.Context) error { + if tc, ok := c.Conn.(tlsIntros); ok { + return tc.HandshakeContext(ctx) + } + return nil +} + +type connSet struct { + mu sync.Mutex + conns map[net.Conn]struct{} +} + +func newConnSet() connSet { + return connSet{conns: make(map[net.Conn]struct{})} +} + +func (cs *connSet) add(c net.Conn) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.conns[c] = struct{}{} +} + +func (cs *connSet) remove(c net.Conn) { + cs.mu.Lock() + defer cs.mu.Unlock() + delete(cs.conns, c) +} + +func (cs *connSet) closeAll() { + cs.mu.Lock() + defer cs.mu.Unlock() + for c := range cs.conns { + _ = c.Close() + } +} diff --git a/internal/network/udp.go b/internal/network/udp.go new file mode 100644 index 0000000..ab1e2bf --- /dev/null +++ b/internal/network/udp.go @@ -0,0 +1,77 @@ +package network + +import ( + "context" + "errors" + "log/slog" + "net" + "os" + "time" + + "github.com/lachlanharrisdev/gonetsim/internal/pcap" +) + +const maxPacketSize = 65535 + +// UDPHandler processes one datagram and returns an optional reply. ses is the +// per-peer capture session, or nil when capture is disabled. +type UDPHandler func(ctx context.Context, data []byte, from net.Addr, ses *pcap.Session) ([]byte, error) + +// ServeUDP handles datagrams sequentially on one socket so replies keep +// receive order and handlers never run concurrently. +func ServeUDP(ctx context.Context, name, addr string, run *pcap.Run, idle time.Duration, log *slog.Logger, h UDPHandler) error { + iface, err := run.NewInterface("gonetsim " + name + " udp") + if err != nil { + return err + } + + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return err + } + defer func() { _ = pc.Close() }() + + var rec *pcap.PacketConn + if run != nil { + rec = pcap.NewPacketConn(pc, run, iface, idle) + pc = rec + } + if rec != nil { + defer rec.CloseAll() + } + + stop := closeOnCancel(ctx, pc) + defer stop() + + log.Info("listening on " + addr + " (udp)") + + buf := make([]byte, maxPacketSize) + for { + n, remote, err := pc.ReadFrom(buf) + if err != nil { + if expectedClose(err, ctx) { + return nil + } + return err + } + + data := make([]byte, n) + copy(data, buf[:n]) + + var ses *pcap.Session + if rec != nil { + ses = rec.SessionFor(remote) + } + + reply, err := h(ctx, data, remote, ses) + if err != nil { + log.Info("packet from " + remote.String() + " failed: " + err.Error()) + continue + } + if reply != nil { + if _, err := pc.WriteTo(reply, remote); err != nil && !errors.Is(err, os.ErrDeadlineExceeded) { + log.Debug("reply to " + remote.String() + " failed: " + err.Error()) + } + } + } +} diff --git a/internal/netx/netx.go b/internal/netx/netx.go deleted file mode 100644 index cfc0051..0000000 --- a/internal/netx/netx.go +++ /dev/null @@ -1,107 +0,0 @@ -package netx - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "io" - "net" - "strconv" - "strings" - "time" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" -) - -func ParseAddr(addr string) (string, error) { - if addr == "" { - return "", fmt.Errorf("listen address is required") - } - if _, err := net.ResolveTCPAddr("tcp", addr); err != nil { - return "", fmt.Errorf("invalid listen address %q (expected host:port): %w", addr, err) - } - return addr, nil -} - -func ValidateNetwork(network string, allowed ...string) error { - n := strings.ToLower(strings.TrimSpace(network)) - for _, a := range allowed { - if n == a { - return nil - } - } - return fmt.Errorf("network must be one of: %s", strings.Join(allowed, ", ")) -} - -func ValidateStatus(code int) error { - if code != 0 && (code < 100 || code > 599) { - return fmt.Errorf("status code must be 0 or between 100 and 599, was %d", code) - } - return nil -} - -func DisplayNetwork(network string) string { - switch strings.ToLower(strings.TrimSpace(network)) { - case "both": - return "udp+tcp" - case "tcp": - return "tcp" - default: - return "udp" - } -} - -func ParsePort(addr string) (int, bool) { - _, portStr, err := net.SplitHostPort(addr) - if err != nil { - return 0, false - } - port, err := strconv.Atoi(portStr) - if err != nil { - return 0, false - } - return port, true -} - -func ListenTCP(addr string, run *capture.Run, iface int, tlsCfg *tls.Config) (net.Listener, error) { - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - ln = capture.NewConnListener(ln, run, iface) - if tlsCfg != nil { - ln = tls.NewListener(ln, tlsCfg) - } - return ln, nil -} - -func ListenUDP(addr string, run *capture.Run, iface int, idle time.Duration) (*capture.PacketConn, error) { - pc, err := net.ListenPacket("udp", addr) - if err != nil { - return nil, err - } - return capture.NewPacketConn(pc, run, iface, idle), nil -} - -func CloseOnCancel(ctx context.Context, c io.Closer) (stop func()) { - stopped := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - _ = c.Close() - case <-stopped: - } - }() - return func() { close(stopped) } -} - -func IsExpectedClose(err error, ctx context.Context) bool { - if err == nil { - return true - } - if errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) { - return true - } - return ctx.Err() != nil -} diff --git a/internal/observability/logging.go b/internal/observability/logging.go deleted file mode 100644 index 30693e0..0000000 --- a/internal/observability/logging.go +++ /dev/null @@ -1,54 +0,0 @@ -package observability - -import ( - "log/slog" - "os" - "strings" - "time" - - "github.com/lmittmann/tint" - "github.com/mattn/go-colorable" - "github.com/mattn/go-isatty" -) - -type Options struct { - Format string - Level string -} - -func NewLogger(cfg Options) (*slog.Logger, error) { - level := parseLevel(cfg.Level) - if strings.ToLower(strings.TrimSpace(cfg.Format)) == "json" { - return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})), nil - } - - w := os.Stderr - noColor := !isatty.IsTerminal(w.Fd()) - - return slog.New(tint.NewTextHandler(colorable.NewColorable(w), &tint.Options{ - Level: level, - TimeFormat: time.TimeOnly, - NoColor: noColor, - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - if len(groups) == 0 && a.Value.Kind() == slog.KindAny { - if _, ok := a.Value.Any().(error); ok { - return tint.Attr(9, a) - } - } - return a - }, - })), nil -} - -func parseLevel(s string) slog.Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "debug": - return slog.LevelDebug - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } -} diff --git a/internal/capture/recorder.go b/internal/pcap/conn.go similarity index 80% rename from internal/capture/recorder.go rename to internal/pcap/conn.go index 41f462e..4c22ae3 100644 --- a/internal/capture/recorder.go +++ b/internal/pcap/conn.go @@ -1,4 +1,4 @@ -package capture +package pcap import ( "context" @@ -9,61 +9,31 @@ import ( "time" ) +// Conn wraps a socket so reads/writes are recorded into a Session. The wrapped +// conn is placed *under* any TLS layer, so a TLS listener records ciphertext +// exactly as it appears on the wire while still exposing the Session for +// capture:comment annotations. type Conn struct { net.Conn ses *Session } -type ConnListener struct { - net.Listener - run *Run - iface int -} - -type udpFlow struct { - ses *Session - last time.Time -} - -type PacketConn struct { - net.PacketConn - run *Run - iface int - idle time.Duration - - mu sync.Mutex - flows map[string]*udpFlow -} - -func NewConnListener(ln net.Listener, run *Run, iface int) net.Listener { +// NewConn wraps c in a recorder when a run is active and the endpoints can be +// mapped to netip.AddrPort. Returns (c, nil) when there is nothing to record. +func NewConn(c net.Conn, run *Run, iface int) (net.Conn, *Session) { if run == nil { - return ln - } - return &ConnListener{Listener: ln, run: run, iface: iface} -} - -func (l *ConnListener) Accept() (net.Conn, error) { - c, err := l.Listener.Accept() - if err != nil { - return nil, err - } - return NewConn(c, l.run, l.iface), nil -} - -func NewConn(c net.Conn, run *Run, iface int) net.Conn { - if run == nil { - return c + return c, nil } local, okL := toAddrPort(c.LocalAddr()) remote, okR := toAddrPort(c.RemoteAddr()) if !okL || !okR { - return c + return c, nil } ses, err := run.NewSession("tcp", local, remote, iface) if err != nil || ses == nil { - return c + return c, nil } - return &Conn{Conn: c, ses: ses} + return &Conn{Conn: c, ses: ses}, ses } func (c *Conn) Session() *Session { @@ -113,6 +83,24 @@ func (c *Conn) HandshakeContext(ctx context.Context) error { return nil } +type udpFlow struct { + ses *Session + last time.Time +} + +// PacketConn wraps a UDP socket, tracking one Session per remote peer. Flows +// idle for longer than idle are closed so a long-running sim does not leak +// sessions or memory. +type PacketConn struct { + net.PacketConn + run *Run + iface int + idle time.Duration + + mu sync.Mutex + flows map[string]*udpFlow +} + func NewPacketConn(pc net.PacketConn, run *Run, iface int, idle time.Duration) *PacketConn { return &PacketConn{PacketConn: pc, run: run, iface: iface, idle: idle, flows: make(map[string]*udpFlow)} } @@ -179,7 +167,6 @@ func (c *PacketConn) record(remote net.Addr, data []byte, fromClient bool) { } f.last = now _ = f.ses.Write(data, fromClient) - _ = f.ses.Flush() } func (c *PacketConn) CloseAll() { diff --git a/internal/capture/run.go b/internal/pcap/run.go similarity index 59% rename from internal/capture/run.go rename to internal/pcap/run.go index 036502a..7818a0b 100644 --- a/internal/capture/run.go +++ b/internal/pcap/run.go @@ -1,4 +1,11 @@ -package capture +// Package pcap writes simulated network traffic to a pcapng file. +// +// GoNetSim does not sniff anything: every handler read/write is synthesised +// into fake Ethernet/IP/TCP|UDP frames and appended to a single run file that +// opens in Wireshark, tshark, or any pcapng reader. Handshakes are synthesised +// (sequence numbers start at 0, MACs are fake) and TLS sessions capture +// ciphertext as seen on the socket, not plaintext. +package pcap import ( "crypto/rand" @@ -7,21 +14,24 @@ import ( "net/netip" "os" "path/filepath" - "runtime" "sync" "time" "github.com/google/gopacket/layers" ) +// Run is a single capture file: one section header, one interface block per +// listener, and one enhanced packet block per frame. type Run struct { - mu sync.Mutex - f *os.File - path string - ifaces int - packets uint64 - first time.Time - last time.Time + mu sync.Mutex + f *os.File + path string + manifest string + ifaces int + packets uint64 + first time.Time + last time.Time + off int64 // current append offset, for retroactive annotations } func NewRunID() string { @@ -30,37 +40,10 @@ func NewRunID() string { return time.Now().Format("20060102-150405") + fmt.Sprintf("-%02x%02x", suffix[0], suffix[1]) } -func DefaultRunsDir() (string, error) { - // XDG_DATA_HOME is honored on all platforms so tests can redirect the - // runs directory via t.Setenv (os.UserCacheDir ignores it on Windows). - if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { - return filepath.Join(xdg, "gonetsim", "runs"), nil - } - var base string - switch runtime.GOOS { - case "windows": - dir, err := os.UserCacheDir() - if err != nil { - return "", err - } - base = dir - case "darwin": - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, "Library", "Application Support") - default: - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, ".local", "share") - } - return filepath.Join(base, "gonetsim", "runs"), nil -} - -func RunPath(output string) (string, error) { +// DefaultPath returns output if set (creating its parent directory), otherwise +// a fresh single-file path in the current directory. Relative by default so it +// works anywhere without platform-specific home-directory lookups. +func DefaultPath(output string) (string, error) { if output != "" { if dir := filepath.Dir(output); dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -69,22 +52,21 @@ func RunPath(output string) (string, error) { } return output, nil } - dir, err := DefaultRunsDir() - if err != nil { - return "", err - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("create runs dir %q: %w", dir, err) - } - return filepath.Join(dir, NewRunID()+".pcapng"), nil + return NewRunID() + ".pcapng", nil } -func NewRun(path string) (*Run, error) { +// NewRun opens path and writes the section header block. When manifest is +// non-empty it is embedded in the SHB as an extra comment option so the single +// file is self-describing to an analyst opening it in Wireshark. +func NewRun(path string, manifest ...string) (*Run, error) { f, err := os.Create(path) if err != nil { return nil, fmt.Errorf("create pcapng %q: %w", path, err) } r := &Run{f: f, path: path} + if len(manifest) > 0 { + r.manifest = manifest[0] + } if err := r.writeSHB(); err != nil { _ = f.Close() _ = os.Remove(path) @@ -115,10 +97,10 @@ func (r *Run) NewInterface(name string) (int, error) { binary.LittleEndian.PutUint16(b[8:10], uint16(layers.LinkTypeEthernet)) binary.LittleEndian.PutUint16(b[10:12], 0) binary.LittleEndian.PutUint32(b[12:16], snapLen) - if err := writeAll(r.f, b); err != nil { + if err := r.write(b); err != nil { return 0, err } - if err := writeAll(r.f, opt); err != nil { + if err := r.write(opt); err != nil { return 0, err } if err := r.writeTrailerLocked(length); err != nil { @@ -161,8 +143,10 @@ func (r *Run) Stats() (packets uint64, first, last time.Time) { func (r *Run) writeSHB() error { opt := encodeOption(2, []byte("GoNetSim simulated network")) - opt = append(opt, encodeOption(3, []byte(runtime.GOOS+"/"+runtime.GOARCH))...) - opt = append(opt, encodeOption(4, []byte("gonetsim"))...) + opt = append(opt, encodeOption(3, []byte("gonetsim"))...) + if r.manifest != "" { + opt = append(opt, encodeOption(2, []byte(r.manifest))...) + } opt = append(opt, encodeOption(0, nil)...) length := 28 + len(opt) b := make([]byte, 24) @@ -172,10 +156,10 @@ func (r *Run) writeSHB() error { binary.LittleEndian.PutUint16(b[12:14], 1) binary.LittleEndian.PutUint16(b[14:16], 0) binary.LittleEndian.PutUint64(b[16:24], 0xFFFFFFFFFFFFFFFF) - if _, err := r.f.Write(b); err != nil { + if err := r.write(b); err != nil { return err } - if _, err := r.f.Write(opt); err != nil { + if err := r.write(opt); err != nil { return err } return r.writeTrailerLocked(length) @@ -184,6 +168,13 @@ func (r *Run) writeSHB() error { func (r *Run) writeTrailerLocked(length int) error { var b [4]byte binary.LittleEndian.PutUint32(b[:], uint32(length)) - _, err := r.f.Write(b[:]) + return r.write(b[:]) +} + +// write appends b to the capture and tracks the file offset so sessions can +// retroactively annotate already-written packets. Callers hold r.mu. +func (r *Run) write(b []byte) error { + n, err := r.f.Write(b) + r.off += int64(n) return err } diff --git a/internal/capture/session.go b/internal/pcap/session.go similarity index 74% rename from internal/capture/session.go rename to internal/pcap/session.go index 314317b..f3a57b5 100644 --- a/internal/capture/session.go +++ b/internal/pcap/session.go @@ -1,20 +1,26 @@ -package capture +package pcap import ( "encoding/binary" "net" "net/netip" - "os" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" ) +const snapLen = 262144 + const ( - snapLen = 262144 + // Every packet reserves a fixed-size comment slot so a capture:comment + // can be retroactively written into the most recent packet without + // shifting the append-only file layout. + maxCommentLen = 256 + commentSlotLen = 4 + maxCommentLen + 4 // opt_comment TLV + endofopt ) +// Session records the frames for one logical connection or UDP flow. type Session struct { run *Run iface int @@ -22,23 +28,39 @@ type Session struct { local netip.AddrPort remote netip.AddrPort - // the pcapng holds no real handshake, so thefirst Write emits SYN, SYN-ACK, - // then data with synthetic seq/ack numbers tracked in clientSeq/serverSeq - // Close emits FIN/FIN-ACK + // the pcapng holds no real handshake, so the first Write emits SYN, + // SYN-ACK, then data with synthetic seq/ack numbers tracked in + // clientSeq/serverSeq; Close emits FIN/FIN-ACK. synSent bool - pending string // comment attached to the next emitted frame + pending string // comment waiting for a packet to attach to + + // lastOptions is the file offset of the comment slot in the most recent + // packet this session wrote; 0 when no packet has been written yet. + lastOptions int64 clientSeq uint32 serverSeq uint32 } +// Comment attaches text to the most recent packet this session emitted (the +// "current" packet, e.g. the line the handler just read). If no packet has +// been written yet it attaches to the next one. A comment that arrives before +// any packet, or when a previous comment already claimed the slot, waits for +// the next packet. func (s *Session) Comment(text string) { if s == nil || s.run == nil { return } s.run.mu.Lock() defer s.run.mu.Unlock() - s.pending = text + if s.lastOptions == 0 { + s.pending = text + return + } + if _, err := s.run.f.WriteAt(commentOption(text), s.lastOptions); err != nil { + // can't rewrite; fall back to the next packet + s.pending = text + } } func (s *Session) Write(data []byte, fromClient bool) error { @@ -72,15 +94,6 @@ func (s *Session) Close() error { return nil } -func (s *Session) Flush() error { - if s == nil || s.run == nil { - return nil - } - s.run.mu.Lock() - defer s.run.mu.Unlock() - return s.run.f.Sync() -} - func encodeOption(code uint16, value []byte) []byte { out := make([]byte, 4+len(value)) binary.LittleEndian.PutUint16(out[0:2], code) @@ -124,7 +137,6 @@ func (s *Session) writeTCP(data []byte, fromClient bool) error { s.serverSeq += uint32(len(data)) } return err - } func (s *Session) emitTCP(fromClient, syn, ackFlag, fin bool, seq, ackNum uint32) error { @@ -134,8 +146,8 @@ func (s *Session) emitTCP(fromClient, syn, ackFlag, fin bool, seq, ackNum uint32 func (s *Session) epb(frame []byte) (int, error) { ts := time.Now() - opts := s.takeComment() - length := 32 + frameLen(frame) + len(opts) + start := s.run.off + length := 32 + frameLen(frame) + commentSlotLen b := make([]byte, 28) binary.LittleEndian.PutUint32(b[0:4], 6) binary.LittleEndian.PutUint32(b[4:8], uint32(length)) @@ -144,18 +156,26 @@ func (s *Session) epb(frame []byte) (int, error) { binary.LittleEndian.PutUint32(b[16:20], uint32(ts.UnixMicro())) binary.LittleEndian.PutUint32(b[20:24], uint32(len(frame))) binary.LittleEndian.PutUint32(b[24:28], uint32(len(frame))) - if err := writeAll(s.run.f, b); err != nil { + if err := s.run.write(b); err != nil { return 0, err } - if err := writeAll(s.run.f, frame); err != nil { + if err := s.run.write(frame); err != nil { return 0, err } if pad := framePad(len(frame)); pad > 0 { - if err := writeAll(s.run.f, make([]byte, pad)); err != nil { + if err := s.run.write(make([]byte, pad)); err != nil { return 0, err } } - if err := writeAll(s.run.f, opts); err != nil { + + // the comment slot: filled from pending now, or retroactively later. + s.lastOptions = start + int64(28+frameLen(frame)) + opts := make([]byte, commentSlotLen) + if s.pending != "" { + copy(opts, commentOption(s.pending)) + s.pending = "" + } + if err := s.run.write(opts); err != nil { return 0, err } if err := s.run.writeTrailerLocked(length); err != nil { @@ -169,12 +189,18 @@ func (s *Session) epb(frame []byte) (int, error) { return len(frame), nil } -func (s *Session) takeComment() []byte { - if s.pending == "" { - return nil +// commentOption encodes a comment slot: an opt_comment option (truncated to +// maxCommentLen), an end-of-options marker, and zero padding out to the fixed +// slot size so retroactive writes never change the block length. +func commentOption(text string) []byte { + if len(text) > maxCommentLen { + text = text[:maxCommentLen] + } + out := encodeOption(1, []byte(text)) // opt_comment + out = append(out, encodeOption(0, nil)...) + for len(out) < commentSlotLen { + out = append(out, 0) } - out := encodeOption(1, []byte(s.pending)) // opt_comment, no nul - s.pending = "" return out } @@ -186,11 +212,6 @@ func framePad(n int) int { return (4 - n%4) % 4 } -func writeAll(f *os.File, b []byte) error { - _, err := f.Write(b) - return err -} - type transportKind int const ( diff --git a/internal/script/api_capture.go b/internal/script/api_capture.go new file mode 100644 index 0000000..74d5155 --- /dev/null +++ b/internal/script/api_capture.go @@ -0,0 +1,18 @@ +package script + +import lua "github.com/yuin/gopher-lua" + +// installCapture registers the capture table. Scripts annotate interesting +// packets with capture:comment("...") which becomes a packet comment in +// Wireshark. Safe to call with a nil Commenter (capture disabled): the +// function silently no-ops. +func installCapture(L *lua.LState, c Commenter) { + t := L.NewTable() + L.SetField(t, "comment", L.NewFunction(func(L *lua.LState) int { + if c != nil { + c.Comment(L.CheckString(2)) + } + return 0 + })) + L.SetGlobal("capture", t) +} diff --git a/internal/handler/luabindings.go b/internal/script/api_conn.go similarity index 56% rename from internal/handler/luabindings.go rename to internal/script/api_conn.go index 61e18f3..302f550 100644 --- a/internal/handler/luabindings.go +++ b/internal/script/api_conn.go @@ -1,111 +1,19 @@ -package handler +package script import ( "context" "crypto/tls" - "log/slog" "net" - "strings" "time" lua "github.com/yuin/gopher-lua" - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/state" + "github.com/lachlanharrisdev/gonetsim/internal/store" ) -func registerState(L *lua.LState, name string, store *state.Store) { - t := L.NewTable() - registerStateMethods(L, t, store) - L.SetGlobal(name, t) -} - -func registerStateMethods(L *lua.LState, t *lua.LTable, store *state.Store) { - L.SetField(t, "get", L.NewFunction(func(L *lua.LState) int { - if v, ok := store.Get(L.CheckString(2)); ok { - L.Push(lua.LString(v)) - } else { - L.Push(lua.LNil) - } - return 1 - })) - L.SetField(t, "set", L.NewFunction(func(L *lua.LState) int { - if err := store.Set(L.CheckString(2), L.CheckString(3)); err != nil { - L.Push(lua.LFalse) - L.Push(lua.LString(err.Error())) - return 2 - } - L.Push(lua.LTrue) - return 1 - })) - L.SetField(t, "has", L.NewFunction(func(L *lua.LState) int { - L.Push(lua.LBool(store.Has(L.CheckString(2)))) - return 1 - })) - L.SetField(t, "delete", L.NewFunction(func(L *lua.LState) int { - store.Delete(L.CheckString(2)) - return 0 - })) -} - -func openLibs(L *lua.LState) { - lua.OpenBase(L) - lua.OpenString(L) - lua.OpenTable(L) - lua.OpenMath(L) - - str := L.GetGlobal("string").(*lua.LTable) - L.SetField(str, "pack", L.NewFunction(luaPack)) - L.SetField(str, "unpack", L.NewFunction(luaUnpack)) - - // base exposes filesystem helpers; drop them - for _, name := range []string{"dofile", "loadfile", "require"} { - L.SetGlobal(name, lua.LNil) - } -} - -func registerLog(L *lua.LState, logger *slog.Logger) { - log := L.NewTable() - for _, e := range []struct { - name string - level slog.Level - }{ - {"info", slog.LevelInfo}, - {"warn", slog.LevelWarn}, - {"error", slog.LevelError}, - } { - fn := L.NewFunction(func(L *lua.LState) int { - logger.Log(context.Background(), e.level, luaStrings(L)) - return 0 - }) - L.SetField(log, e.name, fn) - } - L.SetGlobal("log", log) - - L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int { - logger.Info(luaStrings(L)) - return 0 - })) -} - -func registerCapture(L *lua.LState, ses *capture.Session) { - capture := L.NewTable() - L.SetField(capture, "comment", L.NewFunction(func(L *lua.LState) int { - ses.Comment(L.CheckString(2)) - return 0 - })) - L.SetGlobal("capture", capture) -} - -func luaStrings(L *lua.LState) string { - parts := make([]string, L.GetTop()) - for i := range parts { - parts[i] = L.ToString(i + 1) - } - return strings.Join(parts, " ") -} - -func registerConn(L *lua.LState, lc *luaConn, ctx context.Context, env Env, connState *state.Store) *lua.LTable { +// installConn registers the conn object for TCP handlers. installStateMethods +// is mixed in so a connection carries its own scoped key/value state. +func installConn(L *lua.LState, lc *luaConn, ctx context.Context, env Env, connState *store.Store) *lua.LTable { conn := L.NewTable() L.SetField(conn, "read", L.NewFunction(func(L *lua.LState) int { @@ -213,7 +121,7 @@ func registerConn(L *lua.LState, lc *luaConn, ctx context.Context, env Env, conn return 1 })) - registerStateMethods(L, conn, connState) + installStateMethods(L, conn, connState) return conn } diff --git a/internal/script/api_dns.go b/internal/script/api_dns.go new file mode 100644 index 0000000..9d8b951 --- /dev/null +++ b/internal/script/api_dns.go @@ -0,0 +1,14 @@ +package script + +import lua "github.com/yuin/gopher-lua" + +// installDNS reserves the dns module for the planned high-level DNS sinkhole +// API. A minimal DNS example handler lives in the repo docs for now. +func installDNS(L *lua.LState) { + t := L.NewTable() + L.SetField(t, "answer", L.NewFunction(func(L *lua.LState) int { + L.RaiseError("dns.answer: not implemented yet") + return 0 + })) + L.SetGlobal("dns", t) +} diff --git a/internal/script/api_http.go b/internal/script/api_http.go new file mode 100644 index 0000000..74a097f --- /dev/null +++ b/internal/script/api_http.go @@ -0,0 +1,16 @@ +package script + +import lua "github.com/yuin/gopher-lua" + +// installHTTP reserves the http module for the planned high-level HTTP +// simulation API (status/header/body helpers, session handling). Handlers can +// already simulate HTTP over conn:read_line/write; this stub makes a future +// surface fail loudly instead of silently colliding with scripts. +func installHTTP(L *lua.LState) { + t := L.NewTable() + L.SetField(t, "respond", L.NewFunction(func(L *lua.LState) int { + L.RaiseError("http.respond: not implemented yet (simulate HTTP over conn:read_line/write)") + return 0 + })) + L.SetGlobal("http", t) +} diff --git a/internal/script/api_log.go b/internal/script/api_log.go new file mode 100644 index 0000000..acbeafc --- /dev/null +++ b/internal/script/api_log.go @@ -0,0 +1,43 @@ +package script + +import ( + "context" + "log/slog" + "strings" + + lua "github.com/yuin/gopher-lua" +) + +// installLog registers the log table (log:info/warn/error) and reroutes print +// to the listener's logger. +func installLog(L *lua.LState, logger *slog.Logger) { + log := L.NewTable() + for _, e := range []struct { + name string + level slog.Level + }{ + {"info", slog.LevelInfo}, + {"warn", slog.LevelWarn}, + {"error", slog.LevelError}, + } { + fn := L.NewFunction(func(L *lua.LState) int { + logger.Log(context.Background(), e.level, luaStrings(L)) + return 0 + }) + L.SetField(log, e.name, fn) + } + L.SetGlobal("log", log) + + L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int { + logger.Info(luaStrings(L)) + return 0 + })) +} + +func luaStrings(L *lua.LState) string { + parts := make([]string, L.GetTop()) + for i := range parts { + parts[i] = L.ToString(i + 1) + } + return strings.Join(parts, " ") +} diff --git a/internal/script/api_state.go b/internal/script/api_state.go new file mode 100644 index 0000000..226293d --- /dev/null +++ b/internal/script/api_state.go @@ -0,0 +1,43 @@ +package script + +import ( + lua "github.com/yuin/gopher-lua" + + "github.com/lachlanharrisdev/gonetsim/internal/store" +) + +// installState registers a scoped key/value store as a global table exposing +// get/set/has/delete. Scope depends on the name: conn, handler, or global. +func installState(L *lua.LState, name string, s *store.Store) { + t := L.NewTable() + installStateMethods(L, t, s) + L.SetGlobal(name, t) +} + +func installStateMethods(L *lua.LState, t *lua.LTable, s *store.Store) { + L.SetField(t, "get", L.NewFunction(func(L *lua.LState) int { + if v, ok := s.Get(L.CheckString(2)); ok { + L.Push(lua.LString(v)) + } else { + L.Push(lua.LNil) + } + return 1 + })) + L.SetField(t, "set", L.NewFunction(func(L *lua.LState) int { + if err := s.Set(L.CheckString(2), L.CheckString(3)); err != nil { + L.Push(lua.LFalse) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LTrue) + return 1 + })) + L.SetField(t, "has", L.NewFunction(func(L *lua.LState) int { + L.Push(lua.LBool(s.Has(L.CheckString(2)))) + return 1 + })) + L.SetField(t, "delete", L.NewFunction(func(L *lua.LState) int { + s.Delete(L.CheckString(2)) + return 0 + })) +} diff --git a/internal/script/builtin_echo.go b/internal/script/builtin_echo.go new file mode 100644 index 0000000..d9d9fce --- /dev/null +++ b/internal/script/builtin_echo.go @@ -0,0 +1,29 @@ +package script + +import ( + "context" + "net" +) + +// EchoHandler is the trivial smoke-test builtin: everything it reads is echoed +// back (TCP) or returned as the reply (UDP). +type EchoHandler struct{} + +func (EchoHandler) ServeTCP(_ context.Context, conn net.Conn, _ 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) ServeUDP(_ context.Context, data []byte, _ net.Addr, _ Env) ([]byte, error) { + return data, nil +} diff --git a/internal/script/builtin_sink.go b/internal/script/builtin_sink.go new file mode 100644 index 0000000..7671361 --- /dev/null +++ b/internal/script/builtin_sink.go @@ -0,0 +1,24 @@ +package script + +import ( + "context" + "net" +) + +// SinkHandler consumes and discards all received data. Useful for draining a +// socket without generating noise. +type SinkHandler struct{} + +func (SinkHandler) ServeTCP(_ context.Context, conn net.Conn, _ Env) error { + buf := make([]byte, 32*1024) + for { + _, err := conn.Read(buf) + if err != nil { + return readError(err) + } + } +} + +func (SinkHandler) ServeUDP(_ context.Context, _ []byte, _ net.Addr, _ Env) ([]byte, error) { + return nil, nil +} diff --git a/internal/handler/luaconn.go b/internal/script/conn.go similarity index 76% rename from internal/handler/luaconn.go rename to internal/script/conn.go index 9d98428..052ac18 100644 --- a/internal/handler/luaconn.go +++ b/internal/script/conn.go @@ -1,4 +1,4 @@ -package handler +package script import ( "bufio" @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "strings" @@ -19,13 +20,18 @@ type tlsState interface { HandshakeContext(ctx context.Context) error } +// luaConn adapts a net.Conn for the conn:* API: buffered reads with caps, and +// TLS introspection forwarded through any wrappers (idle deadline, capture). +// logger surfaces TLS handshake failures so the operator can see why conn:sni +// or conn:tls is returning nil. type luaConn struct { net.Conn - br *bufio.Reader + br *bufio.Reader + logger *slog.Logger } -func newLuaConn(conn net.Conn) *luaConn { - return &luaConn{Conn: conn, br: bufio.NewReader(conn)} +func newLuaConn(conn net.Conn, logger *slog.Logger) *luaConn { + return &luaConn{Conn: conn, br: bufio.NewReader(conn), logger: logger} } func (lc *luaConn) tls() tlsState { @@ -33,27 +39,15 @@ func (lc *luaConn) tls() tlsState { return tc } -func (lc *luaConn) ConnectionState() tls.ConnectionState { - if tc := lc.tls(); tc != nil { - return tc.ConnectionState() - } - return tls.ConnectionState{} -} - -func (lc *luaConn) HandshakeContext(ctx context.Context) error { - if tc := lc.tls(); tc != nil { - return tc.HandshakeContext(ctx) - } - return nil -} - func (lc *luaConn) handshake(ctx context.Context) (tls.ConnectionState, bool) { tc := lc.tls() if tc == nil { return tls.ConnectionState{}, false } if !tc.ConnectionState().HandshakeComplete { - _ = tc.HandshakeContext(ctx) + if err := tc.HandshakeContext(ctx); err != nil && lc.logger != nil { + lc.logger.Info("TLS handshake failed: " + err.Error()) + } } st := tc.ConnectionState() if st.Version == 0 { diff --git a/internal/handler/lua.go b/internal/script/engine.go similarity index 72% rename from internal/handler/lua.go rename to internal/script/engine.go index ed3601b..91d92c6 100644 --- a/internal/handler/lua.go +++ b/internal/script/engine.go @@ -1,4 +1,4 @@ -package handler +package script import ( "bytes" @@ -12,7 +12,7 @@ import ( lua "github.com/yuin/gopher-lua" "github.com/yuin/gopher-lua/parse" - "github.com/lachlanharrisdev/gonetsim/internal/state" + "github.com/lachlanharrisdev/gonetsim/internal/store" ) const ( @@ -20,17 +20,21 @@ const ( maxSleep = time.Hour tcpEntry = "handle" // handle(conn) - udpEntry = "handle_packet" // handle_packet(data) -> string | nil + udpEntry = "handle_packet" // handle_packet(data, peer) -> string | nil ) +// LuaHandler serves a Lua script. The chunk is compiled once and each +// connection/packet runs in a fresh sandboxed state, so a handler can never +// carry state from one target to another except through the store it is +// explicitly given. type LuaHandler struct { path string proto *lua.FunctionProto - budget *state.Budget - handlerState *state.Store + budget *store.Budget + handlerState *store.Store } -func NewLua(path string, budget *state.Budget) (*LuaHandler, error) { +func NewLua(path string, budget *store.Budget) (*LuaHandler, error) { src, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read lua script: %w", err) @@ -54,23 +58,23 @@ func NewLua(path string, budget *state.Budget) (*LuaHandler, error) { path: path, proto: proto, budget: budget, - handlerState: state.NewStore(budget), + handlerState: store.NewStore(budget), }, nil } -func (h *LuaHandler) HandleTCP(ctx context.Context, conn net.Conn, env Env) error { +func (h *LuaHandler) ServeTCP(ctx context.Context, conn net.Conn, env Env) error { L := h.newState(env) defer L.Close() - lc := newLuaConn(conn) - connState := state.NewStore(h.budget) - if _, err := h.run(L, tcpEntry, 0, registerConn(L, lc, ctx, env, connState)); err != nil { + lc := newLuaConn(conn, env.Logger) + connState := store.NewStore(h.budget) + if _, err := h.run(L, tcpEntry, 0, installConn(L, lc, ctx, env, connState)); err != nil { return fmt.Errorf("lua %s: %w", h.path, err) } return nil } -func (h *LuaHandler) HandleUDP(_ context.Context, data []byte, remote net.Addr, env Env) ([]byte, error) { +func (h *LuaHandler) ServeUDP(_ context.Context, data []byte, remote net.Addr, env Env) ([]byte, error) { L := h.newState(env) defer L.Close() @@ -133,14 +137,16 @@ func compile(src []byte, name string) (*lua.FunctionProto, error) { func (h *LuaHandler) newState(env Env) *lua.LState { L := lua.NewState(lua.Options{SkipOpenLibs: true}) openLibs(L) - registerLog(L, env.Logger) - registerCapture(L, env.Capture) + installLog(L, env.Logger) + installCapture(L, env.Capture) + installHTTP(L) + installDNS(L) global := env.Global if global == nil { - global = state.NewStore(h.budget) + global = store.NewStore(h.budget) } - registerState(L, "global", global) - registerState(L, "handler", h.handlerState) + installState(L, "global", global) + installState(L, "handler", h.handlerState) return L } diff --git a/internal/handler/handler.go b/internal/script/handler.go similarity index 56% rename from internal/handler/handler.go rename to internal/script/handler.go index 9dd5f05..c63300d 100644 --- a/internal/handler/handler.go +++ b/internal/script/handler.go @@ -1,4 +1,8 @@ -package handler +// Package script owns the handler boundary: resolving a handler spec, running +// sandboxed Lua scripts, and the builtin echo/sink handlers. Everything the +// sandbox exposes to scripts lives in this package, one file per API module, +// so the extension surface is reviewable in a single place. +package script import ( "context" @@ -11,30 +15,27 @@ import ( "strings" "time" - "github.com/lachlanharrisdev/gonetsim/internal/capture" - "github.com/lachlanharrisdev/gonetsim/internal/state" + "github.com/lachlanharrisdev/gonetsim/internal/store" ) +// Commenter is the capture annotation seam. pcap.Session implements it. +type Commenter interface { + Comment(string) +} + +// Env is the per-connection environment handed to a handler. Capture and +// Global may be nil when capture is disabled. type Env struct { Logger *slog.Logger - Capture *capture.Session + Capture Commenter IdleTimeout time.Duration // connection idle timeout, used by conn:sleep - Global *state.Store + Global *store.Store } +// Handler serves TCP connections and/or UDP datagrams. type Handler interface { - TCPHandler - UDPHandler -} - -type TCPHandler interface { - // HandleTCP serves a single accepted connection until it is closed. - HandleTCP(ctx context.Context, conn net.Conn, env Env) error -} - -type UDPHandler interface { - // HandleUDP processes a single datagram and returns an optional reply. - HandleUDP(ctx context.Context, data []byte, remote net.Addr, env Env) ([]byte, error) + ServeTCP(ctx context.Context, conn net.Conn, env Env) error + ServeUDP(ctx context.Context, data []byte, remote net.Addr, env Env) ([]byte, error) } // New resolves a handler spec. Supported forms: @@ -43,8 +44,8 @@ type UDPHandler interface { // builtin:sink - consume and discard all received data // lua: - serve with a Lua script (relative paths resolve against baseDir) // -// A nil budget gives the handler its own private state budget -func New(spec string, baseDir string, budget *state.Budget) (Handler, error) { +// A nil budget gives the handler its own private state budget. +func New(spec, baseDir string, budget *store.Budget) (Handler, error) { scheme, value, ok := strings.Cut(spec, ":") if !ok { return nil, fmt.Errorf("invalid handler %q (expected \"builtin:name\" or \"lua:path\")", spec) diff --git a/internal/script/sandbox.go b/internal/script/sandbox.go new file mode 100644 index 0000000..af6eff5 --- /dev/null +++ b/internal/script/sandbox.go @@ -0,0 +1,24 @@ +package script + +import lua "github.com/yuin/gopher-lua" + +// openLibs opens a curated subset of the Lua standard library: base, string, +// table and math. Everything else (io, os, package, coroutine, debug) is never +// opened, and the filesystem/reflection escape hatches the base library +// otherwise exposes are explicitly removed. Scripts cannot read files, run +// code they did not define, or reach into the host. +func openLibs(L *lua.LState) { + lua.OpenBase(L) + lua.OpenString(L) + lua.OpenTable(L) + lua.OpenMath(L) + + for _, name := range []string{ + "dofile", "loadfile", "load", "loadstring", "require", + "collectgarbage", "newproxy", + "rawget", "rawset", "rawequal", "rawlen", + "getfenv", "setfenv", "module", + } { + L.SetGlobal(name, lua.LNil) + } +} diff --git a/internal/service/manager.go b/internal/service/manager.go deleted file mode 100644 index 2a53fc6..0000000 --- a/internal/service/manager.go +++ /dev/null @@ -1,130 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "log/slog" - "sync" - "time" -) - -type Manager struct { - services []Service - shutdownTimeout time.Duration - logger *slog.Logger -} - -func NewManager(shutdownTimeout time.Duration, logger *slog.Logger) *Manager { - return &Manager{shutdownTimeout: shutdownTimeout, logger: logger} -} - -func (m *Manager) Add(s Service) { - m.services = append(m.services, s) -} - -type serviceExit struct { - svc Service - err error -} - -func (m *Manager) RunAll(ctx context.Context) error { - return runServices(ctx, m.logger, m.shutdownTimeout, m.services) -} - -func runServices(ctx context.Context, logger *slog.Logger, shutdownTimeout time.Duration, services []Service) error { - if len(services) == 0 { - return nil - } - if shutdownTimeout <= 0 { - shutdownTimeout = 5 * time.Second - } - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - exitCh := make(chan serviceExit, len(services)) - - var wg sync.WaitGroup - wg.Add(len(services)) - for _, svc := range services { - service := svc - go runService(&wg, runCtx, logger, service, exitCh) - } - - logger.Info("starting services", "count", len(services)) - - running := len(services) - exited := make(map[Service]bool) - var firstErr error - - for running > 0 { - select { - case <-ctx.Done(): - cancel() - stopRemaining(logger, shutdownTimeout, services, exited) - wg.Wait() - return nil - case ex := <-exitCh: - running-- - exited[ex.svc] = true - if ex.err == nil || errors.Is(ex.err, context.Canceled) { - continue - } - if firstErr == nil { - firstErr = fmt.Errorf("%s: %w", ex.svc.Name(), ex.err) - } - // a service failed; stop the rest so the manager doesn't block - // waiting on services that can never come up - cancel() - stopRemaining(logger, shutdownTimeout, services, exited) - wg.Wait() - return firstErr - } - } - - wg.Wait() - return firstErr -} - -func stopRemaining(logger *slog.Logger, shutdownTimeout time.Duration, services []Service, exited map[Service]bool) { - shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - - var stopWG sync.WaitGroup - for _, svc := range services { - if exited[svc] { - continue - } - service := svc - stopWG.Add(1) - go func() { - defer stopWG.Done() - if err := service.Stop(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) { - logger.Error("service stop error", "service", service.Name(), "err", err) - } - }() - } - stopWG.Wait() -} - -func runService(wg *sync.WaitGroup, ctx context.Context, rootLogger *slog.Logger, service Service, exitCh chan<- serviceExit) { - defer wg.Done() - logger := NewPrefixedLogger(rootLogger, service.Name()) - - defer func() { - if r := recover(); r != nil { - logger.Error("recovered from panic", "panic", r) - exitCh <- serviceExit{svc: service, err: fmt.Errorf("panic: %v", r)} - } - }() - - logger.Info("starting") - err := service.Start(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - logger.Error("stopped with error", "err", err) - } else { - logger.Info("stopped") - } - exitCh <- serviceExit{svc: service, err: err} -} diff --git a/internal/service/service.go b/internal/service/service.go deleted file mode 100644 index 699f10e..0000000 --- a/internal/service/service.go +++ /dev/null @@ -1,16 +0,0 @@ -package service - -import ( - "context" -) - -type Service interface { - // unique identifier for the service, used in logging - Name() string - - // start runs the service blocking until the service stops - Start(ctx context.Context) error - - // stop gracefully shuts down the service within the given context deadline - Stop(ctx context.Context) error -} diff --git a/internal/state/state_test.go b/internal/state/state_test.go deleted file mode 100644 index ac47d83..0000000 --- a/internal/state/state_test.go +++ /dev/null @@ -1,87 +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 state - -import ( - "strings" - "testing" -) - -func TestState(t *testing.T) { - t.Run("roundtrip and accounting", func(t *testing.T) { - b := NewBudget(1024) - s := NewStore(b) - - if err := s.Set("k", "\x00\xffbinary\x00"); err != nil { - t.Fatalf("Set binary: %v", err) - } - v, ok := s.Get("k") - if !ok || v != "\x00\xffbinary\x00" { - t.Fatalf("binary roundtrip = %q, %v", v, ok) - } - if !s.Has("k") { - t.Fatalf("expected key to exist") - } - - // replacing a value must not double-count it - if err := s.Set("k", strings.Repeat("A", 512)); err != nil { - t.Fatalf("Set replace: %v", err) - } - if b.used != 512 { - t.Fatalf("usage after replace = %d, want 512", b.used) - } - if _, ok := s.Get("missing"); ok { - t.Fatalf("expected missing key to be absent") - } - - s.Delete("k") - if s.Has("k") || b.used != 0 { - t.Fatalf("delete: still present, usage %d", b.used) - } - // deleting a missing key is a no-op - s.Delete("k") - }) - - t.Run("shared budget", func(t *testing.T) { - b := NewBudget(10) - a, c := NewStore(b), NewStore(b) - - if err := a.Set("x", strings.Repeat("A", 7)); err != nil { - t.Fatalf("Set: %v", err) - } - if err := c.Set("y", strings.Repeat("B", 3)); err != nil { - t.Fatalf("Set: %v", err) - } - if err := c.Set("z", "overflow"); err == nil || !strings.Contains(err.Error(), "state limit") { - t.Fatalf("expected limit error, got: %v", err) - } - - // freeing in one store makes room in the other - a.Delete("x") - if err := c.Set("z", strings.Repeat("C", 7)); err != nil { - t.Fatalf("Set after free: %v", err) - } - }) - - t.Run("caps", func(t *testing.T) { - s := NewStore(nil) - if err := s.Set("", "v"); err == nil { - t.Fatalf("expected empty key error") - } - if err := s.Set(strings.Repeat("k", MaxKeyLen+1), "v"); err == nil { - t.Fatalf("expected key cap error") - } - if err := s.Set("k", strings.Repeat("v", MaxValueLen+1)); err == nil { - t.Fatalf("expected value cap error") - } - if err := s.Set("k", ""); err != nil { - t.Fatalf("empty value should be allowed: %v", err) - } - }) -} diff --git a/internal/state/state.go b/internal/store/kv.go similarity index 80% rename from internal/state/state.go rename to internal/store/kv.go index 18af17b..42194ad 100644 --- a/internal/state/state.go +++ b/internal/store/kv.go @@ -1,4 +1,8 @@ -package state +// Package store provides a small budgeted string-to-string key/value store. +// Scripts use it for state scoped to a connection, a handler, or the whole +// process. All limits are enforced at write time so one runaway handler +// cannot exhaust memory. +package store import ( "errors" @@ -13,6 +17,7 @@ const ( DefaultTotalLimit = 64 << 20 // 64 MiB ) +// Budget tracks how much of the shared state budget a set of stores has used. type Budget struct { mu sync.RWMutex limit int64 @@ -23,7 +28,7 @@ func NewBudget(limit int64) *Budget { return &Budget{limit: limit} } -// bounded string-to-string map +// Store is a bounded string-to-string map. type Store struct { budget *Budget data map[string]string diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go deleted file mode 100644 index 54551ef..0000000 --- a/internal/testutil/testutil.go +++ /dev/null @@ -1,175 +0,0 @@ -package testutil - -import ( - "io" - "log/slog" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/google/gopacket" - "github.com/google/gopacket/layers" - "github.com/google/gopacket/pcapgo" - "github.com/miekg/dns" - - "github.com/lachlanharrisdev/gonetsim/internal/capture" -) - -func Logger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func FreeTCPAddr(t *testing.T) string { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Listen: %v", err) - } - defer func() { _ = ln.Close() }() - return ln.Addr().String() -} - -func FreePort(t *testing.T, network string) string { - t.Helper() - if network == "udp" { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("ListenPacket: %v", err) - } - defer func() { _ = pc.Close() }() - return pc.LocalAddr().String() - } - return FreeTCPAddr(t) -} - -func MustPort(t *testing.T, addr string) string { - t.Helper() - _, port, err := net.SplitHostPort(addr) - if err != nil { - t.Fatalf("SplitHostPort(%q): %v", addr, err) - } - return port -} - -func NewPcapRun(t *testing.T) (*capture.Run, string) { - t.Helper() - path := filepath.Join(t.TempDir(), "run.pcapng") - run, err := capture.NewRun(path) - if err != nil { - t.Fatalf("NewRun: %v", err) - } - t.Cleanup(func() { _ = run.Close() }) - return run, path -} - -func WaitFor(t *testing.T, timeout time.Duration, msg string, cond func() bool) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if cond() { - return - } - time.Sleep(20 * time.Millisecond) - } - t.Fatalf("timed out waiting: %s", msg) -} - -func TransportPayloads(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer func() { _ = f.Close() }() - r, err := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions) - if err != nil { - return "", err - } - var sb strings.Builder - for { - data, _, err := r.ReadPacketData() - if err != nil { - break - } - pkt := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default) - if u, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP); ok { - sb.Write(u.Payload) - } else if tc, ok := pkt.Layer(layers.LayerTypeTCP).(*layers.TCP); ok { - sb.Write(tc.Payload) - } - } - return sb.String(), nil -} - -func WaitForPayload(t *testing.T, path string, timeout time.Duration, cond func(string) bool) string { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if joined, err := TransportPayloads(path); err == nil && cond(joined) { - return joined - } - time.Sleep(20 * time.Millisecond) - } - joined, _ := TransportPayloads(path) - t.Fatalf("capture %s never satisfied predicate (payloads %q)", path, joined) - return "" -} - -func WaitForPayloadContains(t *testing.T, path, want string, timeout time.Duration) { - t.Helper() - WaitForPayload(t, path, timeout, func(s string) bool { - return want == "" || strings.Contains(s, want) - }) -} - -func DiscardServiceStartErr(t *testing.T, errCh <-chan error) { - t.Helper() - select { - case err := <-errCh: - if err != nil { - t.Fatalf("service.Start returned error: %v", err) - } - case <-time.After(3 * time.Second): - t.Fatalf("service.Start never returned") - } -} - -func RetryGet(t *testing.T, client *http.Client, url string) (int, *http.Response) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) - } - req.Header.Set("Connection", "close") - r, err := client.Do(req) - if err == nil { - return r.StatusCode, r - } - lastErr = err - time.Sleep(20 * time.Millisecond) - } - t.Fatalf("GET %s: %v", url, lastErr) - return 0, nil -} - -func RetryDNSExchange(t *testing.T, client *dns.Client, addr string, m *dns.Msg) (*dns.Msg, time.Duration, error) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - var lastErr error - var lastRTT time.Duration - for time.Now().Before(deadline) { - resp, rtt, err := client.Exchange(m, addr) - if err == nil && resp != nil { - return resp, rtt, nil - } - lastErr, lastRTT = err, rtt - time.Sleep(20 * time.Millisecond) - } - return nil, lastRTT, lastErr -} diff --git a/internal/tlsprovider/config.go b/internal/tlscert/cert.go similarity index 85% rename from internal/tlsprovider/config.go rename to internal/tlscert/cert.go index 1c8c2d9..d6cbe32 100644 --- a/internal/tlsprovider/config.go +++ b/internal/tlscert/cert.go @@ -1,4 +1,9 @@ -package tlsprovider +// Package tlscert provides TLS server certificates for listeners: explicit +// cert/key pairs, an ephemeral in-memory self-signed cert, or a persisted +// self-signed pair plus a CA export. Persistence matters in air-gapped labs +// where a host needs to trust the simulator once and keep it stable across +// restarts. +package tlscert import ( "crypto/tls" @@ -17,12 +22,13 @@ const ( PersistedCAFileName = "gonetsim-ca.pem" ) +// Config selects how a listener gets its certificate. When CertFile is empty +// an ephemeral self-signed certificate is generated in memory for the run. type Config struct { CertFile string KeyFile string - // controls how the fallback self-signed cert is generated - // when the cert or key aren't provided + // controls the fallback self-signed cert generation (DNS names/IPs). SelfSigned SelfSignedOptions // defaults to tls.VersionTLS12 when zero. @@ -36,10 +42,6 @@ func (c Config) Validate() error { return nil } -func DefaultPaths(configDir string) (cert, key string) { - return filepath.Join(configDir, PersistedCertFileName), filepath.Join(configDir, PersistedKeyFileName) -} - func (c Config) TLSConfig() (*tls.Config, error) { if err := c.Validate(); err != nil { return nil, err @@ -54,7 +56,6 @@ func (c Config) TLSConfig() (*tls.Config, error) { if err != nil { return nil, err } - return &tls.Config{MinVersion: minVersion, Certificates: []tls.Certificate{cert}}, nil } @@ -88,7 +89,7 @@ func (c Config) loadOrGenerateCert() (tls.Certificate, error) { return tls.Certificate{}, fmt.Errorf("tls cert and key must exist together: cert=%q key=%q", c.CertFile, c.KeyFile) } - // file missing on disk + // neither file exists yet if !isPersistedAutoPair(c.CertFile, c.KeyFile) { return tls.Certificate{}, fmt.Errorf("tls cert/key not found: cert=%q key=%q", c.CertFile, c.KeyFile) } @@ -115,14 +116,16 @@ func (c Config) loadOrGenerateCert() (tls.Certificate, error) { return tls.X509KeyPair(certPEM, keyPEM) } +// Regenerate removes a persisted auto-pair so the next TLSConfig() call +// creates a fresh one. func (c Config) Regenerate() error { + if !isPersistedAutoPair(c.CertFile, c.KeyFile) { + return errors.New("regenerate only applies to the gonetsim-cert.pem/key.pem pair") + } return c.regeneratePersistedPair() } func (c Config) regeneratePersistedPair() error { - if !isPersistedAutoPair(c.CertFile, c.KeyFile) { - return nil - } for _, p := range []string{c.CertFile, c.KeyFile, caExportPath(c.CertFile)} { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return err diff --git a/internal/tlsprovider/selfsigned.go b/internal/tlscert/selfsigned.go similarity index 89% rename from internal/tlsprovider/selfsigned.go rename to internal/tlscert/selfsigned.go index 918d7cb..cc4e357 100644 --- a/internal/tlsprovider/selfsigned.go +++ b/internal/tlscert/selfsigned.go @@ -1,4 +1,4 @@ -package tlsprovider +package tlscert import ( "crypto/ecdsa" @@ -13,12 +13,14 @@ import ( "time" ) +// SelfSignedOptions tunes certificate generation. type SelfSignedOptions struct { DNSNames []string IPs []net.IP ValidFor time.Duration } +// GenerateSelfSigned returns an ephemeral certificate with a discarded CA. func GenerateSelfSigned(opts SelfSignedOptions) (tls.Certificate, error) { certPEM, keyPEM, _, err := GenerateSelfSignedWithCA(opts) if err != nil { @@ -27,6 +29,9 @@ func GenerateSelfSigned(opts SelfSignedOptions) (tls.Certificate, error) { return tls.X509KeyPair(certPEM, keyPEM) } +// GenerateSelfSignedWithCA builds a fresh CA and a server leaf signed by it. +// certPEM is leaf+CA so servers can present the chain; caPEM is the standalone +// CA for clients that need to trust it. func GenerateSelfSignedWithCA(opts SelfSignedOptions) (certPEM []byte, keyPEM []byte, caPEM []byte, err error) { validFor := opts.ValidFor if validFor == 0 { diff --git a/internal/tlsprovider/tls_test.go b/internal/tlsprovider/tls_test.go deleted file mode 100644 index c32031b..0000000 --- a/internal/tlsprovider/tls_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 tlsprovider - -import ( - "bytes" - "crypto/x509" - "net" - "os" - "path/filepath" - "testing" - "time" -) - -// / -// / basic test for SSL/TLS cert gen. generates a cert & performs basic checks -// / -func TestGenerateSelfSigned_SaneCertificate(t *testing.T) { - cert, err := GenerateSelfSigned(SelfSignedOptions{ - DNSNames: []string{"localhost", "example.test"}, - IPs: []net.IP{net.ParseIP("127.0.0.1")}, - ValidFor: 2 * time.Hour, - }) - if err != nil { - t.Fatalf("GenerateSelfSigned: %v", err) - } - if len(cert.Certificate) == 0 { - t.Fatalf("expected at least one certificate") - } - if cert.PrivateKey == nil { - t.Fatalf("expected PrivateKey to be set") - } - - leaf, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - t.Fatalf("ParseCertificate: %v", err) - } - - if time.Until(leaf.NotAfter) <= 0 { - t.Fatalf("expected certificate to be currently valid") - } - - if leaf.KeyUsage&(x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment) == 0 { - t.Fatalf("expected KeyUsage to include digital signature and/or key encipherment, got %v", leaf.KeyUsage) - } - if len(leaf.ExtKeyUsage) == 0 || leaf.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth { - t.Fatalf("expected ExtKeyUsage to include server auth, got %v", leaf.ExtKeyUsage) - } - - if len(cert.Certificate) < 2 { - t.Fatalf("expected a CA certificate in the chain") - } - ca, err := x509.ParseCertificate(cert.Certificate[1]) - if err != nil { - t.Fatalf("ParseCertificate (ca): %v", err) - } - if !ca.IsCA { - t.Fatalf("expected CA certificate") - } - - // excluded potential checks: - // - leaf.Subject.CommonName != "gonetsim" - // - !leaf.NotAfter.After(leaf.NotBefore) - -} - -func TestTLSConfig_PersistReuseRegenerate(t *testing.T) { - dir := t.TempDir() - - cfg := Config{ - CertFile: filepath.Join(dir, PersistedCertFileName), - KeyFile: filepath.Join(dir, PersistedKeyFileName), - } - - _, err := cfg.TLSConfig() - if err != nil { - t.Fatalf("TLSConfig (first): %v", err) - } - - cert1, err := os.ReadFile(cfg.CertFile) - if err != nil { - t.Fatalf("ReadFile(cert): %v", err) - } - key1, err := os.ReadFile(cfg.KeyFile) - if err != nil { - t.Fatalf("ReadFile(key): %v", err) - } - ca1, err := os.ReadFile(filepath.Join(dir, PersistedCAFileName)) - if err != nil { - t.Fatalf("ReadFile(ca): %v", err) - } - - // Second load must reuse the persisted pair. - _, err = cfg.TLSConfig() - if err != nil { - t.Fatalf("TLSConfig (second): %v", err) - } - - cert2, err := os.ReadFile(cfg.CertFile) - if err != nil { - t.Fatalf("ReadFile(cert, second): %v", err) - } - key2, err := os.ReadFile(cfg.KeyFile) - if err != nil { - t.Fatalf("ReadFile(key, second): %v", err) - } - ca2, err := os.ReadFile(filepath.Join(dir, PersistedCAFileName)) - if err != nil { - t.Fatalf("ReadFile(ca, second): %v", err) - } - - if !bytes.Equal(cert1, cert2) { - t.Fatalf("expected cert to be reused") - } - if !bytes.Equal(key1, key2) { - t.Fatalf("expected key to be reused") - } - if !bytes.Equal(ca1, ca2) { - t.Fatalf("expected CA to be reused") - } - - // Force regeneration must produce a different cert. - if err := cfg.Regenerate(); err != nil { - t.Fatalf("Regenerate: %v", err) - } - if _, err := cfg.TLSConfig(); err != nil { - t.Fatalf("TLSConfig (after regenerate): %v", err) - } - after, err := os.ReadFile(cfg.CertFile) - if err != nil { - t.Fatalf("ReadFile(cert, after): %v", err) - } - if bytes.Equal(cert1, after) { - t.Fatalf("expected cert to be regenerated, but it is identical") - } - - // Freshly generated certs must not be expired. - fresh, err := GenerateSelfSigned(SelfSignedOptions{}) - if err != nil { - t.Fatalf("GenerateSelfSigned: %v", err) - } - if certExpired(fresh) { - t.Fatalf("freshly generated cert must not be expired") - } -} diff --git a/main.go b/main.go deleted file mode 100644 index 80f58e5..0000000 --- a/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/fatih/color" - "github.com/lachlanharrisdev/gonetsim/cmd" - "github.com/mattn/go-isatty" -) - -func main() { - // the banner goes to stderr so stdout stays clean for command output - if isatty.IsTerminal(os.Stderr.Fd()) { - muted := color.New(color.FgHiBlack).SprintFunc() - fmt.Fprintf(os.Stderr, "%s GoNetSim %s. Copyright (c) 2026 Lachlan Harris %s\n\n", muted("=="), cmd.GetVersionLine(), muted("==")) - } else { - fmt.Fprintf(os.Stderr, "== GoNetSim %s. Copyright (c) 2026 Lachlan Harris ==\n\n", cmd.GetVersionLine()) - } - cmd.Execute() -}