Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)/
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<service>`), 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@<instance>`) 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.
18 changes: 11 additions & 7 deletions examples/sourceknock.conf
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions examples/ssh.conf
Original file line number Diff line number Diff line change
@@ -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 </dev/null & done; wait
# (the destination port is irrelevant; only the source port matters.)

[global]
interface = eth0

[trigger:ssh]
service = ssh
port = 22
protocol = tcp
sequence = 7000,8000,9000
window = 5
mode = close_on_connect
fallback_ttl = 300
22 changes: 22 additions & 0 deletions examples/udp/wireguard.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# WireGuard access behind sourceknock.
#
# WireGuard is UDP, so there is no TCP handshake: the port opens on knock and
# stays open until the first UDP packet to the WireGuard port arrives
# (close_on_connect), or until fallback_ttl expires. Pick a WireGuard port in
# 1..65535 that does not clash with other services (51820 is shown as an
# example).
#
# Because UDP has no handshake, the arming knock itself does NOT close the port
# the instant it opens - only a later UDP packet to the service port does.

[global]
interface = eth0

[trigger:wireguard]
service = wireguard
port = 51820
protocol = udp
sequence = 5000,5100,5200
window = 5
mode = close_on_connect
fallback_ttl = 60
20 changes: 20 additions & 0 deletions examples/web.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Web access behind sourceknock.
#
# Knock in the CLIENT'S SOURCE ports to open HTTP and HTTPS for your source IP,
# then close on the first connection. `port` accepts a comma-separated list, so
# both 80 and 443 open together on a successful knock.
#
# NOTE: the INI parser does not strip inline comments from values, so keep
# values free of trailing '#' or ';'.

[global]
interface = eth0

[trigger:web]
service = web
port = 80, 443
protocol = tcp
sequence = 1100,2200,3300
window = 5
mode = close_on_connect
fallback_ttl = 120
Loading
Loading