A port-knocking daemon that knocks via source ports instead of destination
ports. Standard port knocking encodes the secret in the destination port
sequence — a network scanner sees a client hammering a list of ports and can
fingerprint it. sourceknock keeps the destination fixed and varies the
source port instead, so the knock doesn't stand out to a destination-port
scan.
Threat model: source ports are trivially spoofable, like any L4 header field. This gives you stealth / obfuscation (the attack surface stays invisible, noisy scans look normal), not authentication. The real security still comes from your service auth (SSH keys/passwords). Don't rely on sourceknock as the only control.
- The daemon captures packets on an interface (
AF_PACKET, root). - For each packet it extracts the source port (and source IP). Both IPv4 and IPv6 frames are captured and decoded (IPv6 extension headers are walked, bounded).
- A small state machine watches each client's stream of source ports. When it sees the configured ordered sequence within the timing window, it "triggers".
- On trigger the daemon programs nftables to temporarily allow that client's IP to reach the hidden service port, then closes it again (after a fixed TTL, or on first connect).
client server
---- --------
TCP sport=7000 -> dst=X:PORT \
TCP sport=8000 -> dst=X:PORT ==> state machine sees 7000,8000,9000
TCP sport=9000 -> dst=X:PORT / -> adds src IP to nft "allowed" set
TCP sport=<ephemeral> -> dst=X:22 -> port 22 now reachable for this IP
| File | Purpose |
|---|---|
src/state.c |
Pure knock-sequence state machine (no OS deps) |
src/packet.c |
Parse Ethernet/IPv4/IPv6 frames into (src IP, sport, dport, proto) |
src/config.c |
INI-style config parser (knockd-flavoured) |
src/capture.c |
AF_PACKET raw-socket capture (Linux) with frame de-duplication |
src/reward.c |
nftables management: open/close the service port for a client IP |
src/sourceknock.c |
Daemon: wires capture -> state machine -> reward |
tests/ |
Unit tests (run on any OS) |
The core (state machine, packet parsing, config) is deliberately free of socket
code so it unit-tests anywhere. The Linux-only layers (AF_PACKET, nftables)
live in capture.c / reward.c.
make test # build + run unit tests (any OS with a C compiler)
make daemon # build the sourceknock daemon (Linux only; needs kernel headers)Integration testing needs Linux + root (for AF_PACKET and nft).
CI runs on every push and pull request across a Linux and a macOS runner (GitHub Actions):
- Unit tests (
make test) run on both — the core (state machine, packet parsing, config) is cross-platform. - Daemon build (
make daemon) runs on Linux, where theAF_PACKET/ nftables layers compile.
Workflow definitions live in .github/workflows/.
Cut a release by pushing a v* tag. CI builds the Linux daemon and publishes
it as a GitHub Release artifact:
git tag -a v1.0.0 -m "sourceknock 1.0.0"
git push origin v1.0.0The release attaches a sourceknock binary built on ubuntu-latest. (The
daemon is Linux-only; the cross-platform unit tests run in CI on every change.)
[global]
interface = eth0
[trigger:ssh]
service = ssh
port = 22 # hidden service port(s) to open
protocol = tcp # tcp or udp
sequence = 7000,8000,9000 # ordered source-port knock
window = 5 # whole sequence must complete within N seconds
mode = fixed_ttl # fixed_ttl | close_on_connect | permanent
duration = 30 # seconds the port stays open (fixed_ttl)
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 inexamples/).
mode = fixed_ttl— keep the port open fordurationseconds, then close.mode = close_on_connect— close as soon as the client makes a connection (withfallback_ttlas a safety net so a client that never connects can't leave the port open forever).- TCP: the first packet to a service port (the connection's SYN) closes the port.
- UDP: there is no handshake, so the first subsequent UDP packet to a service port closes the port. The arming knock itself does not close the port the instant it opens — only a later packet to the service port does.
mode = permanent— open the port with no timeout, so access persists past any TTL without re-knocking. Pair with an optionaladmin_sequenceto revoke: when that separate knock completes, the client's IP is removed from the allowed set and the port closes again. The admin sequence runs on its own state machine (keyed by source port), so it never interferes with the primary knock.portaccepts a single port, a comma-separated list, or an inclusive range, e.g.port = 22,port = 80, 443, orport = 8000-8002. On a successful knock all listed ports are opened for the client (each port gets its own nftables accept/drop rule, sharing one per-family "allowed" set). Ports must be in 1..65535.
Run it directly (needs root / CAP_NET_RAW for the raw socket and
CAP_NET_ADMIN for nftables):
sudo ./sourceknock /path/to/sourceknock.confOn systemd hosts, install the daemon and its units with make install (needs
root). This places the binary in /usr/local/bin and the units in
/etc/systemd/system, then reloads systemd:
sudo make installThe units run the daemon as the unprivileged sourceknock user, granted only
CAP_NET_RAW and CAP_NET_ADMIN (not root). Create that user first if it
doesn't exist:
sudo useradd --system --user-group --home-dir /var/lib/sourceknock --no-create-home sourceknockThen drop in a config at /etc/sourceknock/sourceknock.conf and start it:
# default unit -> /etc/sourceknock/sourceknock.conf
sudo systemctl enable --now sourceknock
# or a per-instance config via the template unit:
# /etc/sourceknock/myconf.conf
# sudo systemctl enable --now sourceknock@myconfStop / remove:
sudo systemctl disable --now sourceknock # then:
sudo make uninstallsourceknock captures both IPv4 and IPv6 traffic. IPv6 source addresses are
keyed independently from IPv4 in the state machine, and the reward layer keeps
separate allowed_v4 / allowed_v6 nftables sets (an inet table matches both
families, so a single set of rules opens the port for either family). IPv6
extension headers are walked (fragment, hop-by-hop, routing, destination
options), bounded to 8 hops; encrypted L4 (ESP/AH) or non-TCP/UDP next headers
are not decoded.
- Linux only (
AF_PACKET, nftables). - nftables set syntax targets modern
nft(validated on 1.0.9). - De-duplication drops near-identical frames within a 0.5s window, which handles loopback/interfaces that deliver each frame twice.
- Source ports are spoofable — this is obfuscation, not authentication.
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). A plain ./sourceknock conf without privileges fails at
capture open.
- 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 knocksand 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 and the full field reference in the
README Configuration section (or
man 1 sourceknock). - nftables missing or too old. The daemon needs
nftand modern set syntax (validated on 1.0.9). Install nftables (apt install nftables), and note that on GitHub's ubuntu runner thenftablespackage must be installed explicitly (thenftpackage name does not exist there).
- In
fixed_ttlmode the port closes afterdurationseconds (nft element timeout). Inclose_on_connectit closes on the first connection, withfallback_ttlas a safety net. - For UDP there is no handshake, so
close_on_connectfires on the first subsequent UDP packet to the service port — the arming knock itself does not close the port the instant it opens.
The daemon namespaces its nftables table by service name (inet sourceknock_<service>), with separate allowed_v4 / allowed_v6 sets:
nft list set inet sourceknock_ssh allowed_v4If a set never appears or the table is missing, the daemon likely failed to
initialise reward (see above). journalctl -u sourceknock (or -u sourceknock@<instance>) shows the daemon's own logs.
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.
Released under the MIT License. Copyright (c) 2026 Crank-Git.