No description
  • Go 83.1%
  • JavaScript 7.9%
  • CSS 2.5%
  • Makefile 2.5%
  • HTML 2.1%
  • Other 1.9%
Find a file
Claude cb8f04b3e8 Merge branch 'issue27_toolchain_versioning' into 'main'
Warn on build toolchain version drift (#27)

Closes #27

See merge request keyop/go/nfq_forwarder!36
2026-07-08 09:03:49 +00:00
cmd Add --version option 2026-07-05 18:24:37 +01:00
docs Add config editing and service restart to the web UI (GitLab issue #25) 2026-07-07 11:42:59 +01:00
internal Detect concurrent config edits instead of silently losing one (#30) 2026-07-08 06:40:26 +01:00
nodes Add latest Claude conversation export 2026-07-05 16:41:03 +01:00
notes Updated notes with redacted gitlab tokens. 2026-07-05 21:22:03 +01:00
scripts Fix shellcheck 0.10.0 CI failure: suppress SC2317 too (#27) 2026-07-08 10:02:54 +01:00
.gitignore Fix race condition in iptables updates. 2026-07-04 16:34:13 +01:00
.gitlab-ci.yml Address review: version operators, shellcheck (#27) 2026-07-08 08:46:09 +01:00
.golangci.yaml Address MR !19 review comments 2026-07-05 13:14:09 +01:00
.goreleaser.yaml Report tag+commit for non-release builds instead of a bare tag 2026-07-05 18:34:21 +01:00
.tool-versions Address review: version operators, shellcheck (#27) 2026-07-08 08:46:09 +01:00
.yamllint Exclude the Go module cache from yamllint's scan 2026-07-08 07:44:44 +01:00
CLAUDE.md Address review: version operators, shellcheck (#27) 2026-07-08 08:46:09 +01:00
go.mod Replace string-matched netlink timeout detection with net.Error 2026-07-05 18:52:04 +01:00
go.sum Issue #5: Add a web UI. 2026-07-05 11:14:48 +01:00
Makefile Address review: version operators, shellcheck (#27) 2026-07-08 08:46:09 +01:00
nfq_forwarder.pam Issue #5: Add a web UI. 2026-07-05 11:14:48 +01:00
README.md Address review: version operators, shellcheck (#27) 2026-07-08 08:46:09 +01:00
ToDo.md Lint fixup. 2024-07-06 16:13:26 +01:00

nfq_forwarder

Simple(ish) implementation of automatic transparent proxying via ssh tunnels.

Main methodology

This uses iptables heavily, so key is to understand the packet flow order through the various tables/chains.

https://upload.wikimedia.org/wikipedia/commons/3/37/Netfilter-packet-flow.svg

Since we're originating packets from a local process, the order will be (at the network layer):

  • (routing)
  • raw - OUTPUT
  • (conntrack)
  • mangle - OUTPUT
  • nat - OUTPUT
  • filter - OUTPUT
  • mangle - POSTROUTING
  • nat - POSTROUTING

We're using nfqueue to pick up packets, with the NFQUEUE target inserted into the mangle - OUTPUT chain - deliberately before nat - OUTPUT, not after (see GitLab issue #4; an earlier version of this code queued from filter - OUTPUT instead, which sits after nat - OUTPUT in the traversal order above).

When we see a packet intended for one of our destinations (as dictated by the mangle rule), it will be sent to nfqueue (current code defaults to queue number 100), and can be picked up by this code to set up an SSH tunnel and a DNAT rule for this destination, then deliver a verdict on the packet.

Because our NFQUEUE rule runs before nat - OUTPUT, we can deliver a plain NF_ACCEPT verdict once the tunnel and DNAT rule are ready: the packet simply continues its single traversal of the OUTPUT hook, reaching nat - OUTPUT next and getting DNAT'd there by the rule we just inserted - so the exact packet that triggered the tunnel setup is itself forwarded through it, not just whichever packet happens to arrive next.

This ordering is load-bearing, not cosmetic. Netfilter evaluates a single packet's OUTPUT hook traversal across tables in a fixed priority order (as listed above); a verdict can only ever continue forward from, or repeat, the table it was queued from - never rewind to an earlier one. If the NFQUEUE rule lived in filter - OUTPUT (after nat - OUTPUT) as it once did, nat - OUTPUT would already have run - with no match, since the DNAT rule didn't exist yet - by the time the packet reached NFQUEUE; no verdict could then make that same packet benefit from a DNAT rule added afterwards. It would be silently lost, and the connection would only survive if the client's TCP stack happened to retransmit the initial packet a moment later. Queuing one table earlier avoids this entirely, at the cost of every new connection (not just the very first one ever) to an already-tunnelled destination paying a fast userspace round-trip - nat - OUTPUT can no longer transparently rewrite a packet before it ever reaches our rule, since ours now runs first regardless. That round trip is cheap (a map lookup, no dial, no iptables mutation) and is a deliberate trade for never silently dropping a connection's first packet.

iptables and capabilities

iptables normally needs to run as root, but we don't want this executable to be suid-root. The go iptables implementation we're using calls the iptables binary, but locates that via PATH, so we can use a modified version instead. We do not make this iptables suid-root, but instead use the 'legacy' and 'inherited' file capability sets to allow it to be run with enhanced capabilities.

iptables needs

  • CAP_DAC_READ_SEARCH - to lock /run/xtables.lock
  • CAP_NET_ADMIN - to enable modification of iptables tables/chains
  • CAP_NET_RAW - to communicate over the netlink socket to read/write tables/chains

All of these are granted to the copy executable as the Effective and Inherited capability set. Inherited means it can inherit any of these capabilities, and Effective (aka legacy) mean that if a calling process has these in it's Permitted set, the new process will inherit them.

The binary we produce will also need these set in its effective and permitted sets to enable those capabilities at runtime. For a manual install this is handled by the Makefile's install target; for a packaged install (.deb/.rpm/.apk/Arch) it's handled by scripts/nfpm-postinstall.sh instead - see "Packaging" below.

capabilities references:

Signals

When terminated or interrupted (SIGTERM/SIGINT), will close all existing SSH connections and remove all created iptables rules (will all be tagged as nfq_forwarder via the comment module).

SIGUSR1 will cause a list of all current open tunnels to be dumped to stdout.

Version

nfq_forwarder --version prints the version this binary was built from, stamped in at build time via .goreleaser.yaml's builds.ldflags: a real, tagged release build prints just the git tag (e.g. v1.0.2); any other build (make build/make package, or CI runs that aren't the tag-triggered release job) isn't actually that release, so it prints the most recent tag plus a short commit reference instead (e.g. v1.0.2-dev-5809daf). go build/go run outside that pipeline reports dev instead. There is deliberately no -v short alias for --version: -v is already --verbose.

Build toolchain versions

.tool-versions at the repo root pins go/golangci-lint/goreleaser/gitleaks/yamllint/ shellcheck versions this project is built and tested against, each with a comparison operator (==, >=, >, <=, <) - == for a tool where even a patch difference has caused real problems before, >= for a tool where any version at or above a minimum is fine. make build/ test/lint/package/release/coverage/coverage-html all run scripts/check-tool-versions.sh first, which checks every one of those tools against what's actually installed and prints a WARNING: for each one that doesn't satisfy its pin or is missing entirely - but never fails the build, so a version drift is a prompt to investigate rather than a hard gate. Every tool is checked before any warning is printed, so a mismatch in one tool doesn't hide a mismatch in another. This runs the same way locally and in CI (no separate CI step needed - make lint/ make test/make package already depend on it). make lint also runs shellcheck against every script under scripts/.

Recovering from a dropped SSH connection

Each connection's SSH connection to its jump host sends a periodic keepalive request (interval configurable via ssh_keepalive_interval_seconds in a --config YAML file, default 30s) so a connection that dies silently - e.g. a NAT/firewall idle timeout dropping it without either end noticing - is detected promptly, rather than only once the next real connection attempt fails to dial through it. Once detected (via a failed keepalive, or a failed destination dial), the dead connection is evicted and any tunnel that failed is torn down, so the next matching packet is picked up by NFQUEUE again and a fresh SSH connection is dialed automatically - no restart needed.

Unknown jump host keys

By default (ssh_strict_host_key_checking: false in a --config YAML file), the first time a jump host is seen, its SSH key is automatically accepted and recorded in known_hosts_path (Trust On First Use) rather than the connection failing with "knownhosts: key is unknown" - a log line is emitted whenever this happens. A jump host that's already known but presents a different key than recorded is always rejected regardless of this setting, since that's the signal of a possible MITM attack, not an "unknown host" - it's never auto-corrected. Set ssh_strict_host_key_checking: true to require every jump host's key to already be present in known_hosts_path (the original, stricter behavior).

Web UI (optional)

A small web status/control interface can be enabled via web_ui: in a --config YAML file (see docs/config.example.yaml). It shows currently open connections/tunnels, their iptables traffic counters and uptime, and lets you close a specific tunnel (it will automatically re-open the next time matching traffic is seen) - plus a read-only view of the entire running configuration (global settings, defaults, and each connection's effective settings after defaults are merged in).

The configuration can also be edited from the web UI (GitLab issue #25), via a raw YAML textarea or a structured per-field form - a toggle switches between the two, and both are kept in sync (server-side, since there's no in-browser YAML parser). Editing always shows and submits the raw, unresolved on-disk config, not the resolved/defaults-merged view the read-only page shows. Saving validates the submission the same way --config itself is validated at startup, and never writes an invalid config to disk - but a save alone does not take effect: nfq_forwarder has no in-process config-reload path (see "Recovering from a dropped SSH connection" for the only kind of self-healing it does), so a separate "Restart service" action runs systemctl --user restart --no-block nfq_forwarder.service to apply it, matching the existing ExecReload behavior in docs/nfq_forwarder.service.

If two browser sessions have the edit page open at once, whichever one saves second is rejected (not silently overwritten) if the file has changed since that session loaded it - a "Reload latest version" button appears so you can see what changed and re-apply your edit, rather than either session's change being lost without warning.

Login is restricted to the single Linux user account running the nfq_forwarder process, authenticated against that account's real system password via PAM - see nfq_forwarder.pam (the PAM service file to install) and docs/nfq_forwarder.service's comments for the required build-time (libpam0g-dev/pam-devel) and runtime install steps. The listener must bind to a loopback address; there is no TLS support, so put a TLS-terminating reverse proxy in front of it if you need access from another host.

Multi-connection configuration

A --config YAML file can define many connections (queue number/jump host/network/port list) to run concurrently in one process - see docs/config.example.yaml for a fully worked example.

To cut down on repeating the same jump_user/jump_host/forwarded_ports across many connections, a top-level defaults: section supplies fallback values for any of those three fields a connection leaves unset (an explicit value on the connection always wins). defaults.by_network scopes overrides further, to connections whose own network falls within a given CIDR.

Most specific CIDR wins. If a connection's network matches more than one defaults.by_network entry, the entry with the most specific (longest-prefix, smallest-range) matching CIDR is applied, regardless of the order entries are listed in. Only when two matching entries are equally specific does list order decide (first wins). See docs/README.md for more detail and docs/config.example.yaml for a worked example.

queue_number is also optional: if a connection omits it, a stable number is auto-assigned deterministically from that connection's name (which is why name is required whenever queue_number is left unset) - this keeps the number stable across restarts, which matters because a changed queue number after a crash can leave orphaned iptables rules behind (see CLAUDE.md's Design notes for the full reasoning). Set queue_number explicitly only to pin a specific number.

Packaging

.deb, .rpm, .apk (Alpine) and Arch Linux packages are built via goreleaser's nfpm integration (.goreleaser.yaml's nfpms: block) - run make package to produce them locally under dist/. Every format installs nfq_forwarder and a capability-enabled iptables copy to /usr/local/bin and grants the capabilities above via a postinstall script (scripts/nfpm-postinstall.sh), and installs the web UI's PAM service file to /etc/pam.d/nfq_forwarder.

Packages deliberately never touch systemd: nfq_forwarder runs as a per-user systemd --user service, so the unit file, this README, and a sample config are shipped as reference-only copies under /usr/share/doc/nfq_forwarder/ (their sources live in docs/ in this repo) - see docs/README.md for the manual steps to actually enable and start the service after installing a package.