Repository navigation
Conversation
- The service chain dropped every packet to the service port once the client left the allowed set, including packets of established connections. A drop in any base chain is final in nftables, so SSH sessions died right after the handshake (close_on_connect), at TTL expiry (fixed_ttl), and every open session was cut when the daemon started. - Accept `ct state established,related` ahead of the per-port drop rules. - For TCP, also accept non-SYN segments carrying ACK, so sessions that predate conntrack tracking survive daemon startup. They cannot open a connection and a stray ACK gets the same RST as a closed port. - e2e: echo listener; long-lived sessions held across daemon start, TTL expiry and close_on_connect; raw SYN/FIN/NULL/ACK scanner checks.
Keep established sessions alive when the port closes (issue #31)
- close_on_connect added the nft element with no timeout and relied on the daemon's per-packet cleanup to close it, so the port stayed open on a quiet interface, and forever if the daemon crashed or was killed. Add the element with fallback_ttl as its nft timeout instead. - The armed list was a fixed 64-slot array that silently ignored the 65th client; make it growable and refresh the deadline on a re-knock. - `add element` on an existing element keeps its old timeout, so a re-knock never restarted the clock. Delete (quietly) then add. - Fractional timeouts are emitted in ms instead of being truncated. - e2e: quiet-interface expiry, expiry after SIGKILL, 70 concurrent clients armed and expired, re-knock restarts the fixed TTL.
Enforce close_on_connect fallback_ttl in the kernel (issue #32)
- reward_init() ignored every nft exit status and always returned 0, so the daemon logged "listening for knocks" with nft missing or without CAP_NET_ADMIN, and a partial failure could leave a drop rule without its accept rules. Any failing command now aborts init and removes the table. - run_nft() didn't check fork(); waitpid(-1) then failed with status 0 and the command was reported as successful. Check fork() and waitpid() (with EINTR). - Open the capture before touching nftables, and on any later startup failure delete every table already programmed, so the daemon never exits leaving a service port closed with nothing to open it (e.g. a typo'd interface). - e2e: no CAP_NET_ADMIN, unknown interface, conflicting nft set, and a second trigger failing after the first was programmed. Each must exit non-zero, leave no table, and leave the port as it was.
Refuse to start when nftables can't be programmed (issue #37)
) - sk_log() formatted CLOCK_MONOTONIC seconds with localtime(), so log times were time since boot. Use wall-clock time (knock timing stays monotonic). - Install SIGINT/SIGTERM with sigaction() and no SA_RESTART instead of relying on signal()'s feature-macro-dependent semantics. - The capture socket gets a 1s receive timeout and the loop checks the stop flag before every read, so a stop that lands between packets can't be stuck behind a blocking recvfrom() on a quiet interface. - Define _POSIX_C_SOURCE before any include in capture.c and reward.c. - reward_flush() used `delete element ... { * }`, which nft rejects, so the allowed sets were never flushed on shutdown. Use `flush set`. - e2e: wall-clock log timestamps; SIGTERM on an idle interface exits 0 within 1.5s, logs the shutdown, and leaves the allowed set empty.
Wall-clock log timestamps and sigaction-based shutdown (issue #39)
…34) - A key before any [section] dereferenced a NULL section and segfaulted; it is now an error. - A trigger with no `port` started and "opened" nothing; `port` is required. - Service names sanitize/truncate into the nft table name, so `web-1` and `web_1` (or two long names) shared a table and the second trigger flushed the first one's rules. Table names are built in config (config_table_name), must fit without truncation, and must be unique. Empty names are rejected. - Sequence ports must be 1..65535 (source port 0 can't be bound). - Every error path frees what was parsed; repeated keys free the old value; re-parsing frees the previous config (config_free). - The daemon warns about sequences shorter than 3 or using the Linux ephemeral range (config_weak_sequence). - README config example no longer uses inline comments, which don't parse. - Tests: unit tests for each case, the README example and every shipped config parse, and `make test-asan` (ASan + LSan + UBSan) runs in CI.
Reject unusable configs instead of crashing or starting idle (issue #34)
- kn_feed() created a permanent entry for every source IP that sent any packet, and looked clients up with a linear scan, so ordinary scanner noise (or spoofed sources) grew memory and per-packet CPU without limit. - Only a packet whose source port is seq[0] creates state; entries are removed on trigger, reset and timeout. - Fixed-capacity table (KN_DEFAULT_MAX_CLIENTS = 4096, kn_create_bounded() for other sizes) with a seeded hash index and an oldest-first age list: expired entries are swept in O(1), and at the bound a new knock evicts the oldest in-progress one instead of growing. - A one-port sequence now triggers on that port; the old code went to KNOCKING and then read seq.ports[1], past the end of the sequence. - Tests: noise from 100k IPv4 / 20k IPv6 sources leaves no state; removal on trigger/reset; timeout eviction; bound + oldest eviction; a 200k-packet spoofed flood stays at the bound while a real knock completes; hash chain length; interleaved removal; and a 200k-step differential test against a naive reference model of the knock rules.
Bound per-client knock state and stop tracking noise (issue #35)
…36) - The capture fed every frame into the state machine, including frames the host itself sends (PACKET_OUTGOING), so the server's own traffic could drive knock state. Skip them. That also removes loopback's second copy of each frame, so the 2048-entry content-hash de-dup ring (scanned linearly per packet) is gone. - A packet repeating a step that was already matched is now ignored instead of resetting the knock. TCP clients resend each SYN every ~1s and those retransmits land after later steps; any other wrong port still resets. The expected port is checked first, so sequences that reuse a port still work. - Tests: unit tests for retransmits of the previous and older steps, resets on other ports, and port-reusing sequences (differential model updated). e2e: a deterministic A,B,A,C knock, a real TCP connect() knock with SYN retransmits, the server sending from the knock ports, and a loopback knock counted exactly once.
Ignore outgoing frames and tolerate retransmitted knocks (issue #36)
…st (#38) - pkt_parse() treated every link type except its ARPHRD_LOOPBACK as Ethernet, so knocks on interfaces without a link header (wg0, tun, ipip, PPP) were never parsed. It also defined ARPHRD_LOOPBACK as 79; Linux uses 772, so the "2-byte prefix" loopback branch never ran (lo only worked because Linux lo carries an Ethernet header). - Link types are the real Linux ARPHRD values (PKT_ARPHRD_*), classified by pkt_link_kind(): Ethernet-framed (ether, loopback), raw IP (none/tun/wg, ppp, rawip, ipip, ip6tnl, sit), or unsupported (incl. GRE). The dead loopback branch is gone. - capture_open() reads the interface's link type, logs it, and refuses an unsupported one. - Fuzzing: packet.c was built without coverage instrumentation, so libFuzzer ran blind (cov: 2). Build it with -fsanitize=fuzzer-no-link (cov: 41). The target skips the selector byte and maps it to four link types; new raw-IP and unsupported-link seeds. - Tests: raw IPv4/IPv6 on each raw link type, malformed raw frames, unsupported types (incl. 79 and GRE), real lo framing; e2e knock over an ipip tunnel and a GRE interface refused at startup.
- Both units set `Capabilities=CAP_NET_RAW+eip CAP_NET_ADMIN+eip`. systemd removed that option and ignores it, so the daemon ran as the `sourceknock` user with no capabilities: socket(AF_PACKET) failed with EPERM and the unit restart-looped every 5s. Use AmbientCapabilities=, which a non-root User= actually receives and which survives exec of the nft child. - tests/systemd_smoke.sh: make install, create the user, start the default and template units on a dummy interface; assert clean systemd-analyze verify, "listening for knocks", running as sourceknock with CapEff exactly NET_ADMIN+NET_RAW, the nft table programmed, and a clean stop. - CI: new "systemd units" job runs the smoke test on the ubuntu runner.
Parse knocks on raw-IP links; real loopback type (issue #38)
Grant capabilities with AmbientCapabilities= in the systemd units (issue #33)
- Every TCP/UDP frame from a client counted toward its knock whatever its destination, so other traffic from the same address could reset a knock in progress, and replies to the server's outbound connections (remote service port as source) looked like knock steps. - New optional `knock_port = <1..65535>`: when set, only frames sent to that destination port are fed to the primary and admin state machines. The decision is the pure knock_counts() in close.c; close_on_connect detection is unchanged. Unset keeps today's behavior. - Tests: config parsing/validation, knock_counts(); e2e runs a second daemon with knock_port beside the main one on the same interface: interleaved unrelated traffic resets the main knock but not the knock_port one, a knock to another port and reply-shaped traffic are ignored. - e2e cleanup used `pkill -9 -f "$BIN"`, which also matched (and SIGKILLed) its own sudo; match the process name instead. - README and man page document knock_port.
Optional knock_port: only count frames sent to one destination port (issue #40)
) - There was no client, and both documented knocks were broken: examples/ssh.conf used `nc -u -s $p` (-s is the source *address*), raced the steps with `&`, and could send nothing with empty stdin; the man page used `hping3 -D` (debug, not source port) with no count, backgrounded. - src/knock.c: sourceknock-knock sends one UDP datagram per sequence port, bound to that source port, in order, with a configurable delay; IPv4/IPv6, -p destination (knock_port), -s source address. Plain POSIX: builds on Linux and macOS. Clear errors for a source port in use or below 1024. - Built by `make client` and `make test`, installed by `make install` with a man page (sourceknock-knock.1); removed by `make uninstall`. - tests/knock_client_test.py (run by `make test`): real knocks to local UDP receivers checking source ports, order, payload, IPv4/IPv6, -s/-4/-6, bad arguments, port in use, privileged ports. - e2e knocks with sourceknock-knock instead of driver.py. - README: Knocking and NAT sections; examples and man page use the client.
Ship a knock client (sourceknock-knock); fix client docs; document NAT (issue #41)
#53) - README brought up to date with #31-#41: how-it-works diagram (UDP knocks via sourceknock-knock), Layout (close.c, link-type detection, no de-dup, contrib/man/examples), Build & test (test-asan, fuzz, e2e/systemd scripts), CI (sanitizers, fuzz, e2e, systemd job), Releases (daemon + client), config example (knock_port commented, matching the knocking example), install paths, fail-closed stop and how to remove the table, the client table bound, and Troubleshooting for the startup refusals. - release.yml builds and attaches sourceknock-knock alongside the daemon. - The daemon's built-in default config path was /etc/sourceknock.conf; the man page, README, units and `make install` all use /etc/sourceknock/sourceknock.conf. Use that. - Man pages: version 1.1, client in FILES, EXIT STATUS reflects the new startup checks. - Brings main's Dependabot bumps (#29, #30) into dev; the new systemd CI job uses actions/checkout@v7 like the rest.
Release prep for v1.1.0: README audit, ship the knock client (issue #53)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Promote the accumulated
devwork intomainahead of the v1.1.0 release.Every change was reviewed and merged into
devthrough its own issue and PR,each with CI green.
What lands in
mainFixes from the full-codebase review:
fallback_ttlenforced by the kernel; re-knock restarts the timer (PR Enforce close_on_connect fallback_ttl in the kernel (issue #32) #43)portrequired, table-name collisions, leaks (PR Reject unusable configs instead of crashing or starting idle (issue #34) #46)AmbientCapabilities=) (PR Grant capabilities with AmbientCapabilities= in the systemd units (issue #33) #50)New features:
knock_port(PR Optional knock_port: only count frames sent to one destination port (issue #40) #51)sourceknock-knockclient, NAT docs, broken client snippets removed (PR Ship a knock client (sourceknock-knock); fix client docs; document NAT (issue #41) #52)Release prep:
Scope
43 files changed, 2973 insertions(+), 472 deletions(-).
Behavior changes
port, collidingservice names, source port 0 in a sequence.
that predate the daemon survive; they can't open a connection and look like a
closed port to scans (covered by e2e).
/etc/sourceknock/sourceknock.conf.Verification
CI on
devafter the last merge: unit tests on Linux and macOS (state100,234 / packet 104 / config 208 / close 19 / client 24 checks), sanitizers,
fuzz, e2e (fixed_ttl 42/42, close_on_connect 44/44), systemd units (14/14),
format / lint / secrets — all green.