This is the developer guide for Arnika's key I/O layer. It describes the architecture that every key source and every key sink plugs into, the contracts a module must satisfy, the naming and build conventions to follow, and the step-by-step procedure for adding a new module.
It is deliberately backend-agnostic. Anything specific to one backend —
its configuration, its remote prerequisites, its build and deployment steps —
belongs in that module's own document under docs/, never here.
Documentation rule: every key reader and key writer module is documented in exactly one file at
docs/<module-name>.md, where<module-name>matches the adapter file name inrepositories/. For examplerepositories/wireguard-mikrotik.go→docs/wireguard-mikrotik.md. A_GOOSor_GOARCHsuffix is not part of the module name:repositories/wireguard-netlink-netns_linux.gois still thewireguard-netlink-netnsmodule, documented atdocs/wireguard-netlink-netns.md. A module is not finished until that document exists.
Arnika follows a ports-and-adapters (hexagonal) design for key I/O:
- A Key Reader is a source of key material. It answers the question "give me the next key". Examples: a QKD/KMS server, a PQC key file.
- A Key Writer is a sink for key material. It answers the question "install this PSK into WireGuard". Examples: the local WireGuard kernel interface, a remote MikroTik router.
Each side is a thin service (the port) wrapping a repository (the
adapter). The service defines a small interface; each backend is one
implementation of that interface. main.go only ever talks to the
services, so adding or replacing a backend never changes the core key-exchange
logic.
flowchart LR
subgraph Readers["KEY READERS (runtime-selected)"]
direction TB
KMS["HTTPKMSRepository<br/>(QKD / managed)"]
PQC["FilePQCRepository<br/>(PQC / unmanaged)"]
RNEW["your reader<br/>(managed or unmanaged)"]
end
subgraph Core["main.go / setPSK()"]
direction TB
RS["KeyReaderService"]
KDF["HKDF derive<br/>(QKD + PQC)"]
WS["KeyWriterService"]
RS --> KDF --> WS
end
subgraph Writers["KEY WRITERS (compile-time / build tags)"]
direction TB
NL["WireguardNetlinkRepository<br/>tag: default / wireguard_netlink"]
MT["WireguardMikrotikRepository<br/>tag: wireguard_mikrotik"]
WNEW["your writer<br/>tag: wireguard_yourbackend"]
end
KMS --> RS
PQC --> RS
RNEW --> RS
WS --> NL
WS --> MT
WS --> WNEW
The key asymmetry: readers are selected at runtime, writers at compile time. See the two sections below for why.
Arnika distinguishes two classes of platform, and the distinction decides what a module is allowed to do when a dependency is not portable:
| Class | Platforms | Meaning |
|---|---|---|
| Supported | linux/amd64, linux/arm64 |
Deployment targets. Released, integration-tested, documented. |
| Build-only | darwin/amd64, darwin/arm64 |
Must compile, so that maintainers can build, test and run editor tooling on macOS. Not a deployment target, and never exercised against a real kernel. |
Both classes are covered by the build matrix in
.github/workflows/ci.yml. test, lint and the integration
jobs run on Linux only, which makes Linux the reference platform for every correctness
check.
A backend whose dependency does not build on a build-only platform carries a platform
constraint (see rule 2). It does not cause that
platform to be dropped from CI: the darwin entries exist precisely to catch a non-portable
dependency leaking into a package that main imports, and removing them removes the
signal.
| Module | Kind | Adapter | Build tag | Platform | Document |
|---|---|---|---|---|---|
kms |
Reader (managed) | repositories/kms.go |
(always compiled) | any | pending — see KMS.md |
pqc |
Reader (unmanaged) | repositories/pqc.go |
(always compiled) | any | pending |
wireguard-netlink |
Writer | repositories/wireguard-netlink.go |
(default) / wireguard_netlink |
linux (compiles elsewhere, no device) | docs/wireguard-netlink.md |
wireguard-netlink-netns |
Writer | repositories/wireguard-netlink-netns.go |
wireguard_netlink_netns |
linux | docs/wireguard-netlink-netns.md |
wireguard-mikrotik |
Writer | repositories/wireguard-mikrotik.go |
wireguard_mikrotik |
any | docs/wireguard-mikrotik.md |
| Concern | Port (service) | Adapter interface | Adapters (repositories) |
|---|---|---|---|
| Read keys | services/keyreader.go KeyReaderService |
KeyReaderManaged, KeyReaderUnmanaged |
repositories/kms.go, repositories/pqc.go |
| Write keys | services/keywriter.go KeyWriterService |
keyWriterRepository (SetPSK, InvalidateTunnel) |
repositories/wireguard-netlink.go, repositories/wireguard-mikrotik.go |
A module called <module-name> (lower-case, dash-separated) occupies a fixed
set of paths. Following them is what makes a module discoverable:
| Path | Purpose | Writer-selection tag? |
|---|---|---|
repositories/<module-name>.go |
The adapter — all backend logic | No — always compiled. May carry a platform constraint |
repositories/<module-name>_test.go |
Adapter unit tests | No — always run. Same platform constraint as the adapter |
<moduletag>.go (repo root) |
Wiring: the getKeyWriterService factory |
Yes (writers only) |
docs/<module-name>.md |
The module's single document | — |
Three rules follow from that table and are worth stating explicitly:
-
The adapter is never excluded by a writer-selection tag. Only the root wiring file carries a
wireguard_*constraint. This keeps every adapter compiled, tested, vetted and linted on every ordinarygo test ./...run on the reference platform, regardless of which backend the shipped binary selects. -
A platform constraint is a different thing, and is permitted. A backend that depends on a platform-bound kernel feature or package constrains its adapter — and its test file — with an explicit
//go:build linux, and its wiring file withwireguard_<backend> && linux://go:build linux // Platform constraint only: <dependency> is Linux-only. // This is not a writer-selection tag; the adapter still compiles, // vets, lints and tests on every ordinary `go test ./...` run. package repositories
This does not weaken rule 1. Linux is the reference platform (see Platform Support), so a Linux-constrained adapter stays fully covered by
testandlint. What rule 1 forbids is hiding an adapter behind the tag that selects it, because that would remove it from those jobs entirely.An equivalent
_linux.gofilename suffix also works and is idiomatic Go. Prefer the explicit//go:buildline here, so a reader who knows rule 1 can see immediately that the constraint is deliberate and is not a writer-selection tag. -
Backend-specific configuration is read in the wiring file, not in
config/config.go. The sharedconfig.Configstays transport-agnostic; a backend that needs a URL, credentials, a CA bundle or a namespace path reads them from the environment behind its own build tag.
Build tags use the wireguard_<backend> form (underscores — Go build tags
cannot contain dashes), while file and document names use dashes.
The reader service distinguishes two flavours of source:
| Flavour | Interface | Semantics | Example backend |
|---|---|---|---|
| Managed | KeyReaderManaged |
Keys carry an ID. GetNewKey() returns (keyID, key); the peer can later fetch the same key with GetKeyByID(keyID). |
QKD via KMS (ETSI GS QKD 014) |
| Unmanaged | KeyReaderUnmanaged |
Keys have no ID. GetNewKey() returns only the key. |
PQC key file |
// services/keyreader.go
type KeyReaderUnmanaged interface {
GetNewKey() (key []byte, err error)
}
type KeyReaderManaged interface {
GetNewKey() (keyID string, key []byte, err error)
GetKeyByID(keyID *string) (key []byte, err error)
}Readers return raw key bytes, not base64. KeyReaderService wraps them
into a models.Key and tags it managed or unmanaged; the base64
encoding happens once, in setPSK, immediately before handing the PSK to the
writer.
All readers are compiled into every binary and are wired in
keyreader.go (getQKDService, getPQCService). Which key
material actually ends up in the PSK is decided at runtime by the MODE
and PQC_PSK_FILE configuration — no rebuild required. This is appropriate
because the existing backends are lightweight (an HTTP client and a file
reader) and users routinely switch modes on the same binary.
- Write the adapter at
repositories/<module-name>.goimplementing eitherKeyReaderManagedorKeyReaderUnmanaged. Handle key material carefully: decode inside asecret.Do(...)block andclear()every intermediate buffer, asrepositories/pqc.godoes. - Add a constructor
New<Backend>Repository(...)that takes everything it needs as arguments — no global state, no directos.Getenvin the adapter. - Wire it in
keyreader.gowith aget<Backend>Servicefunction that assigns the adapter to the matching interface variable and passes it toservices.NewKeyReaderService. - Select it at runtime from a
config.Configfield, following the patterncfg.UsePQC()establishes — a reader is enabled by configuration, not by a build tag. - Test the adapter with
httptest(network backends) or at.TempDir()fixture (file backends). - Document it at
docs/<module-name>.mdand add a row to the Module Index.
Unlike readers, only one key writer is compiled into any given binary, and the choice is made with a Go build tag. This keeps each binary minimal and platform-appropriate: the netlink writer assumes a local WireGuard kernel module, while a remote-API writer talks over HTTPS and needs neither. Compile-time selection means the unused backend's code and any of its dependencies are simply not part of the shipped binary.
Every writer adapter implements the same two-method contract:
// services/keywriter.go
type keyWriterRepository interface {
InvalidateTunnel() error // Invalidate the WireGuard session by setting a random PSK
SetPSK(psk string) error // Set the PSK on the WireGuard interface
}Contract notes for implementers:
pskarrives base64-encoded — 32 raw bytes, standard encoding. Pass it through as-is unless the backend needs another representation.SetPSKmust be idempotent and re-resolving. It is called on every rotation interval, so resolve the target peer on each call rather than caching a handle or an internal id that a backend restart may invalidate.InvalidateTunnelis the fail-safe.setPSKinmain.gocalls it whenever no valid key material is available, and it must tear the session down by installing a fresh random 32-byte PSK. Generate it fromcrypto/rand(or the backend's own key generator) — never a fixed value.- Errors are surfaced and logged by the caller; return wrapped errors with enough context to identify the interface and peer.
The mechanism is a single factory function, getKeyWriterService(cfg), that is
defined in exactly one file, chosen by build constraint:
| File | Build constraint |
|---|---|
wireguardnetlink.go |
//go:build wireguard_netlink || !wireguard_mikrotik |
wireguardmikrotik.go |
//go:build wireguard_mikrotik |
main.go calls getKeyWriterService(cfg) without knowing which file provides
it. The constraints are designed so that netlink is the default and so that
you can never accidentally compile two writers at once:
-tags passed |
netlink file included? | mikrotik file included? | Result |
|---|---|---|---|
| (none) | ✅ (!wireguard_mikrotik) |
❌ | netlink (default) |
wireguard_netlink |
✅ | ❌ | netlink (explicit) |
wireguard_mikrotik |
❌ | ✅ | mikrotik |
wireguard_netlink wireguard_mikrotik |
✅ | ✅ | ❌ compile error — getKeyWriterService redeclared |
The last row is intentional: requesting both backends is a mistake, and the duplicate-symbol error catches it at build time rather than silently picking one.
This is a load-bearing property, not a side effect — the writer decides which interface receives the PSK, so "two tags silently pick one" is exactly the class of mistake that must not survive a build.
Warning
The property only holds while the default keeps its leading wireguard_netlink ||
clause. Writing the constraint as a bare conjunction of negations looks equivalent and
selects the same writer for every single tag, but it makes the netlink file lose to
every other tag instead of colliding with it, so the conflicting pairs build silently.
The writers job in .github/workflows/ci.yml asserts every
pair still fails; do not change this constraint without running it.
-
Write the adapter at
repositories/<module-name>.goimplementingSetPSKandInvalidateTunnelas described above. Do not put a writer-selection tag on this file. Take the HTTP client (or equivalent transport) as a constructor argument so that TLS trust and timeouts are configured once, at the wiring layer.If the backend depends on something that does not build on every platform in Platform Support, add
//go:build linuxto the adapter and its test file, per rule 2. Check it the way CI does:GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 GOEXPERIMENT=runtimesecret go build ./...
-
Add the wiring file at the repo root, named after the tag, e.g.
wireguardfoo.go://go:build wireguard_foo // ... or, for a platform-bound backend: wireguard_foo && linux package main func getKeyWriterService(cfg *config.Config) (*services.KeyWriterService, error) { // read backend-specific env vars here, build the transport, // then: return services.NewKeyWriterService(repo), nil }
Fail fast: return a descriptive error for every missing mandatory setting rather than letting the first key rotation discover it.
-
Update the default constraint. So that exactly one writer compiles, add your tag to the negated clause of the netlink default in
wireguardnetlink.go, keeping the leadingwireguard_netlink ||clause intact://go:build wireguard_netlink || (!wireguard_mikrotik && !wireguard_foo)Dropping that leading clause silently disables the duplicate-symbol trap — see the warning under The build-tag mechanism.
-
Add tests at
repositories/<module-name>_test.go. For a network backend, stand up anhttptest.Serverthat impersonates the remote API and assert on the requests the adapter makes — seerepositories/wireguard-mikrotik_test.gofor a worked example, including the check thatInvalidateTunnelproduces a fresh 32-byte key on each call. -
Verify the wiring compiles.
go test ./...andgolangci-lintrun against the default (netlink) build, so a tagged wiring file is not covered by them. Check it explicitly:GOEXPERIMENT=runtimesecret go vet -tags wireguard_foo ./... GOEXPERIMENT=runtimesecret go build -tags wireguard_foo .And confirm the safety net still fires — this must fail with a duplicate
getKeyWriterService:GOEXPERIMENT=runtimesecret go build -tags "wireguard_netlink wireguard_foo" .
-
Document it at
docs/<module-name>.mdand add a row to the Module Index. -
Optionally add a Makefile target mirroring
build-mikrotik. This is a convenience only — the generic form below always works without touching theMakefile.
The Makefile exposes the writer tag through the BUILD_TAGS
variable, so any backend can be built without editing it:
make # netlink (default)
make build BUILD_TAGS=wireguard_mikrotik # generic form — works for any tag
make build-netlink # netlink (convenience target)
make build-mikrotik # mikrotik (convenience target)Equivalently, with go build directly:
GOEXPERIMENT=runtimesecret go build . # netlink (default)
GOEXPERIMENT=runtimesecret go build -tags wireguard_mikrotik . # mikrotik
GOEXPERIMENT=runtimesecretis mandatory for everygocommand —build,testandvetalike. Arnika importsruntime/secretto keep key material out of memory dumps, and without the experiment enabled the build fails withbuild constraints exclude all Go files in .../runtime/secret. TheMakefilesets it for you; export it once in a shell that runsgodirectly.golangci-lintneeds no env var —.golangci.ymlalready carries thegoexperiment.runtimesecretbuild tag.
The build is pure Go (CGO_ENABLED=0), so any platform can be targeted by
setting GOOS/GOARCH — no cross C-toolchain is required. The version string
is stamped into main.Version at link time with -X 'main.Version=…' (the
Makefile derives it from git describe --tags --always). Which tag a binary
was built with can be read back with go version -m <binary>.
# Linux arm64, tag from the module's own document, version from git describe
GOOS=linux GOARCH=arm64 make build BUILD_TAGS=wireguard_mikrotik
# Same, with an explicit version override
GOOS=linux GOARCH=arm64 VERSION=v2.0.0a make build BUILD_TAGS=wireguard_mikrotikPer-backend build recipes, including the exact output names and any
backend-specific constraints, belong in docs/<module-name>.md.
Before considering a module done:
- Adapter at
repositories/<module-name>.go, without a writer-selection tag - Platform-bound backends:
//go:build linuxon the adapter and its test file,wireguard_<tag> && linuxon the wiring, and a Platform row indocs/<module-name>.md -
GOOS=darwin go build ./...passes (build-only platform stays green) - Constructor takes all dependencies as arguments (no global state)
- Key material cleared with
clear()/ handled insidesecret.Do(...) - Writers:
SetPSKre-resolves its target on every call - Writers:
InvalidateTunnelinstalls a fresh random 32-byte PSK - Wiring file added, and the netlink default constraint updated (writers)
- Backend config read in the wiring file, not in
config.Config - Tests at
repositories/<module-name>_test.gopass undergo test ./... -
go vet -tags <tag> ./...andgo build -tags <tag> .pass - Every
gocommand above run withGOEXPERIMENT=runtimesecret - Building with two writer tags still fails with a duplicate symbol, for every pair, not just the one you added
- Tag and its supported
GOOSvalues added to thewritersjob in.github/workflows/ci.yml - Long-lived resources released: anything opened per
SetPSKcall is closed on every path, including the error paths -
docs/<module-name>.mdwritten, including a Platform row - Row added to the Module Index, with its Platform value
- Key exchange protocol flow:
CODEFLOW.md - KMS / QKD integration:
KMS.md - Security model and key handling:
SECURITY.md - Deployment:
INSTALL.md - Go build constraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints