A modern, authenticated port-knocking toolkit in Go: one daemon (knockd) that
watches knock ports and executes actions, one client (knock) that performs the
sequence.
cmd/knockd server: knock ports → AEAD auth → rule action → delayed cleanup
cmd/knock client: sends the sequence, prints the sealed response
Unlike classic knockd, the port sequence carries no secret. It is only a routing decoration: every packet must still pass XChaCha20-Poly1305 authentication with a per-packet HKDF-derived key. Knowing the port order gets an attacker nothing.
中文文档:README_zh-CN.md
| classic knockd | knockkit | |
|---|---|---|
| Transport | TCP or UDP | both, mixed freely inside one sequence |
| Secret | the sequence itself | 32-byte PSK per client, AEAD per packet |
| Replay | not covered | nonce cache, fail-closed |
| Response | none | sealed status + trace id (no oracle for outsiders) |
| Actions | firewall open/close | allow, allow-close, exec, webhook, db-update, log |
| Delayed close | external cron | built-in scheduler: knock now, auto-close in N minutes |
make build
# 1. key material
./bin/knockd -gen-psk > psk.key # 32 random bytes, base64
# 2. server config (see examples/knockd.yaml), then:
./bin/knockd -check -config knockd.yaml # validate only
./bin/knockd -config knockd.yaml
# 3. client config (see examples/knock.yaml), then:
./bin/knock -config knock.yaml -rule ssh -v
ok rule=ssh trace=2b6c6061Exit codes for knock: 0 accepted, 1 rejected/no answer, 2 usage error.
A rule is an ordered list of steps; each step is a port plus a transport. Steps may mix transports:
rules:
- name: ssh
action: allow
params: {ports: "22", proto: tcp}
steps:
- {port: 7001, proto: udp}
- {port: 7002, proto: udp}
- {port: 7003, proto: tcp} # final step: the sealed reply arrives hereRules are rejected at startup if one sequence is a prefix of another (the packet would mean two things). Every step of a knock is a complete, freshly sealed packet — an off-path attacker cannot corrupt server state by injecting a single spoofed datagram, because the payload would not authenticate.
Actions are a server-side whitelist; a client can only pick a rule, never a command, URL or SQL statement.
| action | purpose | idempotent |
|---|---|---|
allow |
open ports in the firewall (with TTL) | yes |
allow-close |
close them again — the natural cleanup | yes |
log |
audit entry only | yes |
exec |
run an argv-array binary (no shell), from a configured whitelist | only with allow_as_cleanup: true |
webhook |
POST to a pre-declared URL, no redirects followed | no |
db-update |
execute a pre-registered SQL statement with bound placeholders | no |
Parameter handling:
- config params are authoritative — they may use
$CLIENT_ID,$SOURCE_IP,$RULE,$TRACE, expanded server-side; - a client may add a param only if the key is listed in the rule's
allow_params, and only if the action's schema accepts it (checked at startup); - every param — client or config — is validated against the action's schema; unknown keys are rejected.
cleanup:
after: 3m
action: allow-close
params: {ports: "22", proto: tcp}
policy: refresh # refresh (default) | ignore
retry: {max_attempts: 5, base_delay: 5s, max_delay: 1m}refresh(default): re-knocking resets the timer — the port stays open while the client keeps knocking, and closes 3 minutes after the last knock.ignore: the first timer wins; re-knocks do not postpone it.- Tasks are persisted (
scheduler.store: file) before the timer is armed, so a daemon crash loses nothing; overdue tasks run at the next start. - Only idempotent actions may be used as cleanup — enforced at startup.
ttl: 10mon anallowrule is sugar for exactly this block.
- AEAD is the security boundary. XChaCha20-Poly1305 over the packet; the header (ports, timestamp, client id, flags) is additional authenticated data.
- Per-packet key: HKDF-SHA256(psk, salt=
knockkit-v1‖nonce, info=aead/knock) — nonce compromise never exposes the PSK. - Uniform rejection: every authentication failure produces the identical
auth: rejected; the machine-readable reason exists only in local metrics. There is no oracle on the wire. - Replay: nonces enter the cache only after successful decryption, so an attacker without the PSK cannot fill it; the cache is bounded and fails closed.
- Admission first: ban table → per-IP token bucket → global PPS → parse → AEAD. Expensive work happens only for traffic that passed rate limiting.
- Fail closed on the interesting paths: a full nonce cache or a saturated knock executor drops the packet rather than admitting unverified work.
knockd -check— validate configuration and exit.- Metrics:
metrics.listenexposes/metrics(Prometheus text format) and/healthz. Key series:knockd_auth_fail_by_reason_total,knockd_knock_ok_total,knockd_cleanup_err_total,knockd_rate_drop_total. - Every knock is logged with a
traceid that is echoed to the client and reused by the cleanup task, so one id correlates A, B and client retries. - Logs never contain key material or ciphertext.
nftables(Linux default): tableknockkit, concat set with timeout, priority-10chain — the kernel enforces the TTL even if the daemon dies.iptables: injected atINPUT 1with aknockkitcomment; relies on the application-layer cleanup for closing.noop: tests, non-Linux, or firewall managed elsewhere.
flush_on_exit: true (default) retracts everything the daemon added.
cmd/knockd, cmd/knock binaries
pkg/protocol wire format, params TLV, response, framing (fuzzed)
pkg/auth HKDF, AEAD, freshness, replay cache, keyring (fuzzed)
pkg/seq per-IP knock state machine + ambiguity checks
pkg/action action registry, schemas, built-in actions
pkg/firewall nftables / iptables / noop backends
pkg/scheduler delayed tasks: epochs, persistence, retry
pkg/ratelimit token buckets, global PPS, ban table
internal/config strict YAML (KnownFields), defaults, validation
internal/server pipeline: admit → auth → seq → execute → respond
make test # unit + end-to-end
make race
make vet
make fuzz # 30s per parserGo ≥ 1.27 (per go.mod), no CGO. Server targets Linux; the client is
cross-platform.