Skip to content

Promote dev → main for v1.1.0 (#31-#41, #53) - #55

Merged
Crank-Git merged 27 commits into
mainfrom
dev
Nov 3, 2026
Merged

Crank-Git merged 27 commits into
mainfrom
dev

Conversation

@Crank-Git

Copy link
Copy Markdown
Owner

Purpose

Promote the accumulated dev work into main ahead of the v1.1.0 release.
Every change was reviewed and merged into dev through its own issue and PR,
each with CI green.

What lands in main

Fixes from the full-codebase review:

New features:

Release prep:

Scope

43 files changed, 2973 insertions(+), 472 deletions(-).

Behavior changes

  • Configs the daemon used to accept are rejected: no port, colliding
    service names, source port 0 in a sequence.
  • Startup failures exit non-zero and remove any tables already programmed.
  • For TCP services, non-SYN segments carrying ACK are accepted so sessions
    that predate the daemon survive; they can't open a connection and look like a
    closed port to scans (covered by e2e).
  • Default config path is /etc/sourceknock/sourceknock.conf.

Verification

CI on dev after the last merge: unit tests on Linux and macOS (state
100,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.

- 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)
@Crank-Git
Crank-Git merged commit 9f0a232 into main Nov 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant