- Go 83.1%
- JavaScript 7.9%
- CSS 2.5%
- Makefile 2.5%
- HTML 2.1%
- Other 1.9%
Warn on build toolchain version drift (#27) Closes #27 See merge request keyop/go/nfq_forwarder!36 |
||
|---|---|---|
| cmd | ||
| docs | ||
| internal | ||
| nodes | ||
| notes | ||
| scripts | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .golangci.yaml | ||
| .goreleaser.yaml | ||
| .tool-versions | ||
| .yamllint | ||
| CLAUDE.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| nfq_forwarder.pam | ||
| README.md | ||
| ToDo.md | ||
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:
- https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/33528.pdf
- https://sites.google.com/site/fullycapable
- https://unix.stackexchange.com/questions/128394/passing-capabilities-through-exec
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.