diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..92198c7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,112 @@ +# Contributing to sourceknock + +Thanks for caring about this project. This guide covers building, testing, +formatting, fuzzing, and the branch/PR workflow. For the threat model, +configuration reference, and running/operating instructions, see the +[README](README.md). + +## Requirements + +- A C11 compiler (`cc`/`gcc`/`clang`). +- For the daemon and the Linux-only layers: Linux with kernel headers, plus + `AF_PACKET` and nftables access (root, or `CAP_NET_RAW` + `CAP_NET_ADMIN`). +- For formatting: a real **clang-format 17.0.6**. The pip `clang-format` + wrapper does **not** support `--check`, so CI installs a pinned binary in a + venv. To match locally: + + ```sh + python3 -m venv /tmp/cf_venv + /tmp/cf_venv/bin/pip install "clang-format==17.0.6" + export PATH="/tmp/cf_venv/bin:$PATH" + ``` + +## Build + +```sh +make # build the daemon (Linux) + all unit tests +make daemon # build just the sourceknock daemon (Linux only) +``` + +On macOS the Makefile auto-selects an older Xcode SDK whose +`libSystem.B.tbd` does not reference `arm64e.x1`, and passes it via +`-isysroot`. If your default SDK fails to link, the Makefile falls back to a +known-good one. + +## Test + +```sh +make test # build + run the cross-platform unit tests +``` + +This runs `state_test`, `packet_test`, `config_test`, and `close_test` against +the pure core (no socket code), so it works on any OS with a C compiler. + +### Integration (e2e) test — Linux + root only + +`tests/e2e_integration.sh` drives a full knock cycle inside a Linux network +namespace over a real veth capture interface and real nftables: it knocks, +asserts the service port opens, then asserts it closes again (on the fixed TTL +or on first connect). It exits non-zero if any expectation is wrong. + +```sh +# on a Linux host with sudo: +sudo bash tests/e2e_integration.sh tests/e2e_fixed.conf +sudo bash tests/e2e_integration.sh tests/e2e_close.conf +``` + +The harness runs hermetically in a private temp dir, so it needs no +pre-existing scratch directory. It requires the daemon to be built at +`./sourceknock` first (`make daemon`). + +## Formatting & lint + +```sh +make format # reformat src/ and tests/ with clang-format +make check-format # fail if anything is not clang-format clean +make tidy # clang-tidy (non-blocking in CI) +``` + +`make check-format` must pass on every PR. Use the pinned clang-format 17.0.6 +(see above) so your output matches CI. The code style is Allman braces. + +## Fuzzing + +The packet parser (`src/packet.c`) is the fuzz target, because it decodes +untrusted, variable-length Ethernet/IPv4/IPv6 frames. + +```sh +make fuzz-seeds # regenerate the seed corpus (Python, any host) +make fuzz # build + run the libFuzzer target with ASan (Linux, clang) +``` + +`make fuzz` needs clang with libFuzzer and AddressSanitizer; it fails (non-zero) +if it finds a crash. The CI fuzz run is a bounded, Linux-only job. + +## Branch & PR workflow + +- **One issue = one feature branch + one PR.** Create the branch from + `dev` (the integration branch), never from `main`: + + ```sh + git checkout -b feature/your-issue origin/dev + ``` + +- Keep `dev` and `main` protected; **never push to `dev` or `main` directly.** +- Open a PR against `dev`. Each PR should be small and self-contained, with a + commit message that references the issue (e.g. `... (issue #18)`). +- CI runs on every push and PR: unit tests on Linux and macOS, the daemon + build and e2e test on Linux, plus clang-format, clang-tidy, and a secrets + scan. All must be green before merge. + +## What to test before you push + +1. `make test` is green. +2. `make check-format` reports "All files clang-format clean". +3. If you touched the daemon or the parser, run the e2e test on Linux (see + above) and, ideally, `make fuzz` for a bounded run. + +## Adding a config example + +If you add a sample under `examples/`, make sure it parses: the daemon loads it +at startup (`config_parse_file`), and `make test`'s `config_test` exercises the +parser. A malformed example will fail the daemon's own startup check. diff --git a/Makefile b/Makefile index 6ad4ff9..cd4f017 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # make daemon build the sourceknock daemon (requires Linux headers) # make fuzz build + run the libFuzzer pkt_parse target (Linux, needs # clang with libFuzzer + AddressSanitizer) -# make install install the daemon + systemd units (Linux, needs root) +# make install install the daemon + systemd units + man page (Linux, needs root) # make uninstall remove them again # make clean @@ -49,6 +49,7 @@ PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin SYSDIR ?= /etc/systemd/system CONFDIR ?= /etc/sourceknock +MANDIR ?= $(PREFIX)/share/man UNIT_DIR := contrib/systemd # The units run as the unprivileged 'sourceknock' user (see README); that user # must exist before the service starts. @@ -147,6 +148,10 @@ sourceknock: $(DAEMON_OBJ) install: daemon install -d -m 0755 $(DESTDIR)$(BINDIR) install -m 0755 sourceknock $(DESTDIR)$(BINDIR)/sourceknock + # The man page ships alongside the systemd units, so it is installed by the + # same Linux-only 'install' target (see the comment above). + install -d -m 0755 $(DESTDIR)$(MANDIR)/man1 + install -m 0644 man/sourceknock.1 $(DESTDIR)$(MANDIR)/man1/ install -d -m 0755 $(DESTDIR)$(SYSDIR) install -m 0644 $(UNIT_DIR)/sourceknock.service $(DESTDIR)$(SYSDIR)/ install -m 0644 $(UNIT_DIR)/sourceknock@.service $(DESTDIR)$(SYSDIR)/ diff --git a/README.md b/README.md index b39a153..7b64956 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ fallback_ttl = 300 # safety max open time (close_on_connect) admin_sequence = 9000,8000,7000 # optional revoke knock (permanent mode) ``` +> **Note:** the parser strips only full-line comments (lines starting with `#` +> or `;`). Inline comments after a value are **not** stripped, so keep value +> lines free of trailing `#`/`;` (see the notes in `examples/`). + - `mode = fixed_ttl` — keep the port open for `duration` seconds, then close. - `mode = close_on_connect` — close as soon as the client makes a connection (with `fallback_ttl` as a safety net so a client that never connects can't @@ -189,6 +193,62 @@ are not decoded. handles loopback/interfaces that deliver each frame twice. - Source ports are spoofable — this is obfuscation, not authentication. +## Troubleshooting + +### The daemon won't start / "capture open failed (need root?)" + +`AF_PACKET` raw sockets need root or `CAP_NET_RAW`, and programming nft rules +needs `CAP_NET_ADMIN`. Run it as root, or under the systemd unit (which grants +only those two capabilities to the unprivileged `sourceknock` user — see +[Running](#running)). A plain `./sourceknock conf` without privileges fails at +capture open. + +### The port never opens + +- **Wrong interface.** `[global] interface =` must be the daemon's capture + interface (the one that sees the client's frames), not the client's. +- **No knock seen.** The knock is encoded in the client's **source** port, not + the destination port. Verify the daemon logs `listening for knocks` and that + your client binds each probe to the right source port. +- **Sequence/window mismatch.** The ordered source-port sequence and the + window must match between client and daemon. See the samples in + [examples](examples) and the full field reference in the + [README Configuration](#configuration) section (or `man 1 sourceknock`). +- **nftables missing or too old.** The daemon needs `nft` and modern set + syntax (validated on 1.0.9). Install nftables (`apt install nftables`), and + note that on GitHub's ubuntu runner the `nftables` package must be installed + explicitly (the `nft` package name does not exist there). + +### The port stays open (TTL / close-on-connect not firing) + +- In `fixed_ttl` mode the port closes after `duration` seconds (nft element + timeout). In `close_on_connect` it closes on the first connection, with + `fallback_ttl` as a safety net. +- For UDP there is no handshake, so `close_on_connect` fires on the first + subsequent UDP packet to the service port — the arming knock itself does not + close the port the instant it opens. + +### Checking what the daemon programmed + +The daemon namespaces its nftables table by service name (`inet +sourceknock_`), with separate `allowed_v4` / `allowed_v6` sets: + +```sh +nft list set inet sourceknock_ssh allowed_v4 +``` + +If a set never appears or the table is missing, the daemon likely failed to +initialise reward (see above). `journalctl -u sourceknock` (or `-u +sourceknock@`) shows the daemon's own logs. + +### IPv6 notes + +IPv6 source addresses are keyed independently from IPv4, and the daemon walks +IPv6 extension headers (bounded to 8 hops). Encrypted L4 (ESP/AH) or +non-TCP/UDP next headers are not decoded, so a knock over those protocols +cannot be matched. Ensure your client actually sends over the family you +configured. + ## License Released under the [MIT License](LICENSE). Copyright (c) 2026 Crank-Git. diff --git a/examples/sourceknock.conf b/examples/sourceknock.conf index 4b4bf41..3f31bf5 100644 --- a/examples/sourceknock.conf +++ b/examples/sourceknock.conf @@ -1,17 +1,21 @@ # example sourceknock configuration # # The knock is encoded in the CLIENT'S SOURCE PORT. Keep the destination fixed -# (here, anything — the daemon only looks at source port + source IP). +# (here, anything - the daemon only looks at source port + source IP). +# +# NOTE: the INI parser strips only full-line comments (lines starting with # +# or ;). Inline comments after a value are NOT stripped, so keep value lines +# free of trailing '#' or ';'. [global] interface = eth0 [trigger:ssh] service = ssh -port = 22 # hidden service port to open +port = 22 protocol = tcp -sequence = 7000,8000,9000 # ordered source-port knock -window = 5 # whole sequence must finish within N seconds -mode = fixed_ttl # fixed_ttl | close_on_connect | permanent -duration = 30 # seconds port stays open (fixed_ttl) -fallback_ttl = 300 # safety max open time (close_on_connect) +sequence = 7000,8000,9000 +window = 5 +mode = fixed_ttl +duration = 30 +fallback_ttl = 300 diff --git a/examples/ssh.conf b/examples/ssh.conf new file mode 100644 index 0000000..1a42778 --- /dev/null +++ b/examples/ssh.conf @@ -0,0 +1,22 @@ +# SSH access behind sourceknock. +# +# The knock is encoded in the CLIENT'S SOURCE ports (7000, 8000, 9000). The +# daemon opens TCP/22 for your source IP and closes it as soon as the SSH +# connection is established (close_on_connect), with a 300s safety net in case +# a client never connects. +# +# Knock from a client by binding each probe to the source port, e.g.: +# for p in 7000 8000 9000; do nc -u -s $p -w1 server 22 ''). +.IP \(bu 2 +.B port +The hidden service port(s) to open. Accepts a single port, a comma-separated +list, or an inclusive range, e.g. +.BI 22 , +.BI "80, 443" , +or +.BI "8000-8002" . +.IP \(bu 2 +.B protocol +.BI tcp +or +.BI udp . +.IP \(bu 2 +.B sequence +The ordered source-port knock, e.g. +.BI 7000,8000,9000 . +.IP \(bu 2 +.B window +Seconds within which the whole sequence must complete (default 5). +.IP \(bu 2 +.B mode +One of +.BI fixed_ttl , +.BI close_on_connect , +or +.BI permanent . +.IP \(bu 2 +.B duration +Seconds the port stays open with +.BI fixed_ttl +(default 30). +.IP \(bu 2 +.B fallback_ttl +Safety maximum open time for +.BI close_on_connect +so a client that never connects cannot leave the port open forever (default 300). +.IP \(bu 2 +.B admin_sequence +Optional revoke knock for +.BI permanent +mode; when it completes the client's IP is removed and the port closes again. +.RE +.SH MODES +.RS 4 +.IP \(bu 2 +.B fixed_ttl \- keep the port open for +.BI duration +seconds, then close. +.IP \(bu 2 +.B close_on_connect \- close as soon as the client makes a connection, with +.BI fallback_ttl +as a safety net. +.IP +For TCP the first packet to a service port (the connection's SYN) closes the +port. For UDP there is no handshake, so the first subsequent UDP packet to a +service port closes it; the arming knock itself does not close the port the +instant it opens. +.IP \(bu 2 +.B permanent \- keep the port open with no timeout. Pair with +.BI admin_sequence +to revoke. +.RE +.SH EXAMPLES +Open SSH (port 22) for 30 seconds after a knock: +.IP +.BR "sudo sourceknock /etc/sourceknock/sourceknock.conf" "" +.PP +Knock from a client by sending each probe with the right SOURCE port (7000, +8000, 9000) toward the fixed service port — the destination port is +irrelevant — then connect: +.IP +.nf +# hping3 -D sets the SOURCE port; the knock lives there, not the dest port +for p in 7000 8000 9000; do + hping3 -D $p --udp -i u1000 server /dev/null & +done +wait +ssh -p 22 user@server +.fi +.PP +Run under systemd (installs the binary and units, then reloads systemd): +.IP +.nf +sudo make install +sudo useradd --system --user-group --home-dir /var/lib/sourceknock \ + --no-create-home sourceknock +sudo systemctl enable --now sourceknock +.fi +.SH FILES +.TP +.BI /etc/sourceknock/sourceknock.conf +Default configuration file. +.TP +.BI /usr/local/bin/sourceknock +Installed daemon binary (see +.BR make install ). +.TP +.BI /etc/systemd/system/sourceknock.service +Default systemd unit. +.TP +.BI /etc/systemd/system/sourceknock@.service +Per-instance systemd template unit. +.SH DEBUGGING +To inspect what the daemon has programmed, list the nftables set for a +service. With a trigger named +.BI ssh : +.IP +.BR "nft list set inet sourceknock_ssh allowed_v4" "" +.PP +Common problems (permissions, missing/old nftables, IPv6 quirks) are covered in +the project's +.B CONTRIBUTING.md +and the Troubleshooting section of the README. +.SH EXIT STATUS +.RS 4 +.IP \(bu 2 +.B 0 +The daemon ran until signalled. +.IP \(bu 2 +.B 1 +Configuration error, reward/state-machine setup failure, or capture open +failure (usually missing privileges). +.RE +.SH SEE ALSO +.BR systemctl (8), +.BR nft (8), +.BR hping3 (8) +.PP +Project documentation and contributor guide: +.B https://github.com/Crank-Git/sourceknock#readme +.SH AUTHOR +Written and maintained by Crank-Git. +Released under the MIT License.