# ADR 6 — Host WireGuard overlay<no value>

<!-- Generated by website/sync-docs.py from docs/adr/0006-host-wireguard-overlay.md. Edit that file, not this one. -->

Status: accepted

## Context

[Issue #7](https://github.com/Corium-OS/Corium/issues/7) asks for a way to
declare a WireGuard interface on the node itself, from the `corium:` block. With
it, nodes across sites or providers can form one cluster over an encrypted
overlay (a private network that runs on top of the public one). The issue
carries the same caveat that [issue #5](https://github.com/Corium-OS/Corium/issues/5)
carried for RAID: add the feature only if it is better than a plain cloud-init
WireGuard setup. Decision 9 in `AGENTS.md` is the standing rule behind that
caveat. Every abstraction has an escape hatch, and `write_files` plus `runcmd`
are always available. So the field must justify itself. The escape hatch does
not have to justify its absence.

Three facts must be established before the decision means anything. Each of
them changes what "the plain cloud-init alternative" actually is on this
operating system.

**The problem is a host problem, and the CNI does not solve it.** k0s can
already encrypt pod-to-pod traffic. kube-router and Calico both offer a
WireGuard or IPsec mode. But that encryption operates *between pods*, and it
assumes that the *hosts* can already reach one another. Across sites or
providers, the hosts cannot. The only address that every node shares is its
public address. Without an overlay, the traffic of the control plane itself
crosses the open internet: kubelet to API server, etcd peer replication, the
konnectivity tunnel, and the join API. A host overlay gives every node a stable
address in one private range, and k0s runs over it. That is a different problem
from the one that the CNI solves, and this feature exists for it. Without that
problem, the feature has no case. The problem is real.

**The tooling is an image concern, and half of it is already met.** On a mutable
distribution, "a plain cloud-init WireGuard setup" starts with
`packages: [wireguard-tools]`. On Corium, that step does nothing. `/usr` is
read-only and the package set is fixed at build time. `05-corium.cfg` disables
`package_upgrade`/`package_update` on purpose, so that a first-boot install
fails early with a clear error, not late with an unclear one. So the tooling
cannot be a boot concern at all. It is an image concern. This is a checked fact
about the base image, not an assumption. The kernel module is already there. The
Fedora 44 base carries `wireguard.ko.xz` under
`/usr/lib/modules/<kver>/kernel/drivers/net/wireguard/`. The module is present
but not loaded, and that *is* the disabled state. A `modules-load.d` entry only
makes the load early and deterministic. The userspace half is not there.
`rpm -q wireguard-tools` on `quay.io/fedora/fedora-bootc:44` reports that the
package is not installed. So `wg`, `wg-quick` and the `wg-quick@.service`
template are all absent. The decision is therefore to make them present: add
`wireguard-tools` to the Corium image, present but with nothing enabled. This is
the same posture that ADR 5 takes for `sshd` (present, but with an empty guest
list). A `wg-quick@.service` template that nobody instantiates is not a running
service. So the cost is one small, dormant package, not a listening surface. The
escape-hatch recipe needs that same one line anyway.

This fact comes first because it removes the tooling from the comparison between
a field and a recipe. Both use the same image with the same dormant tools. Three
other things separate them: where the configuration can come from, which address
the node registers, and where the private key is stored. This ADR argues from
those three things.

**NetworkManager is the only renderer.** `05-corium.cfg` records that the image
runs NetworkManager and that nothing else manages the network. `systemd-networkd`
is not the active renderer. So a `.netdev`/`.network` approach means a second
network daemon next to NetworkManager. That gives two authorities over the same
interfaces, which is the type of problem that decision 7 rejected Ignition to
avoid. Two paths to create the interface remain: `wg-quick` that reads
`/etc/wireguard` (needs `wireguard-tools`), or a NetworkManager keyfile
connection. The keyfile is native and needs no extra package, but it couples the
design to NM and still writes the key into `/etc`. The decision below picks one
path and records the other as an alternative.

## Decision

Add a `wireguard:` block to the `corium:` schema. The block describes **this
node's** participation in a host overlay: one or more interfaces, each with its
address, listen port, private key (inline or resolved at boot), and peers.
`corium-agent` provisions it at first boot, before k0s, in the same way as
`raid[]`.

The first reason to prefer the field is where it can be read from. A recipe is
made of `write_files` and `runcmd`. These are cloud-init modules, and they run
only where cloud-init runs. But Corium does not depend on cloud-init for its
configuration, by design. `internal/source` resolves the `corium:` block from a
chain: `/etc/corium/config.yaml` first, then cloud-init, then the kernel command
line, then an image default. The chain exists because "bare metal without a seed
device, PXE, and pre-baked appliances all need a configuration and have no
datasource." For this feature, that is not an edge case. It is the main case. An
overlay exists to join nodes across sites and providers. That is where
on-premise and bare-metal nodes without a cloud-init datasource are most common.
On those nodes, the recipe never runs. A `corium:` field is read from
`/etc/corium/config.yaml` or the kernel command line like any other field. The
escape hatch is weakest in exactly the environment that the feature targets.

Beyond that reach, the field owns three things that a recipe cannot own well.
Together with the reach, they are the whole justification:

1. **The interface is up before k0s starts, and stays a boot-time artifact.**
   `corium-agent` runs only on first boot. The overlay must survive every reboot
   after that, and it must be up before the kubelet registers. So the agent
   creates a persistent unit: `wg-quick@<name>.service` over an
   `/etc/wireguard/<name>.conf` file that the agent writes. The agent enables the
   unit. It brings the interface up synchronously during bootstrap, before it
   renders k0s or detects the node address. It then writes a drop-in on the k0s
   unit that orders k0s `After=`/`Requires=` the interface. This reuses the same
   mechanism that `internal/bootstrap/raid.go` uses to make k0s wait for a mount
   (`requireMountsForK0s`). If the overlay of a node is down, the node does not
   half-join a cluster. k0s refuses to start and gives the reason.

2. **The overlay address becomes the address the node registers.** This is the
   `nodeip.go` failure class, and it is the reason why a recipe is not enough.
   Today, `--node-ip` is passed only when HA is enabled. For a plain cross-site
   cluster, the kubelet picks the first address that the interface scan finds.
   That address is on the physical NIC, not on the overlay. The node then
   registers an address that its peers cannot reach, and logs, exec,
   port-forward and metrics all fail. This is the same delayed, confusing
   failure that `detectNodeIP` was written to prevent for the VRRP virtual IP. A
   cloud-init `runcmd` cannot fix it, because the choice of address happens
   *inside `corium-agent`*, after cloud-init finished. The operator can mark one
   interface as the cluster address of the node. `corium-agent` registers that
   address with k0s. On a controller, the address is also added to the SANs of
   the API server. When HA also runs over the overlay, `detectNodeIP` selects
   from that interface, not from the default route.

3. **The private key is handled as a secret.** A `write_files` recipe puts the
   WireGuard private key in cleartext into the instance metadata.
   `join.tokenFrom`, `ha.authPassFrom` and `api.operatorCAFrom` exist to prevent
   exactly that. The field reuses `SecretSource`. `privateKeyFrom` resolves the
   key at boot from an HTTPS URL or a local file, with the existing `waitFor`
   retry semantics, and `internal/secret` never logs it. `corium-agent` writes
   the rendered `/etc/wireguard/<name>.conf` with mode `0600`, owned by root, and
   redacts the key in all of its reports. The *public* key of a peer is not a
   secret and stays inline. A peer preshared key is a secret, and gets the same
   `...From` treatment.

### Schema sketch

These are not the final types. This is a sketch of the surface, to discuss its
shape, not to implement it. It is a list, like `raid[]`, because a node can in
principle carry more than one interface. One interface is the expected case.

```yaml
corium:
  role: controller+worker
  cluster:
    # The overlay address, so kubeconfigs and joining nodes use the tunnel.
    endpoint: 10.10.0.1
  wireguard:
    - name: wg0                 # becomes the interface name; /dev-style unique per node
      address: 10.10.0.1/24     # this node's address on the overlay (CIDR)
      listenPort: 51820         # optional; omit for a client-only node
      nodeAddress: true         # this interface carries the address k0s registers
      mtu: 1420                 # optional
      privateKeyFrom:           # SecretSource: exactly one of url/file, never inline in prod
        file: /run/corium/wg0.key
        waitFor: 10m
      peers:
        - publicKey: "base64=="            # not a secret; inline
          endpoint: controller.example:51820
          allowedIPs: [10.10.0.0/24]
          persistentKeepalive: 25          # optional; needed behind NAT
          presharedKeyFrom:                # SecretSource; optional, defence in depth
            file: /run/corium/wg0.psk
```

An inline `privateKey:` field exists next to `privateKeyFrom` for labs. It
mirrors `join.token`/`join.tokenFrom`. The documentation calls it a liability for
the same reason: anything that can read the instance metadata can read the key.

### Validation rules

Validation is offline and reports all problems at once with `errors.Join`. This
is the same contract as the rest of `Validate()`: no network, no filesystem, and
every problem reported in one boot.

- `wireguard[].name`: required, unique across the block, and a valid Linux
  interface name (`^[a-z0-9][-a-z0-9]*$`, ≤ 15 characters, the `IFNAMSIZ`
  limit).
- `wireguard[].address`: required, a valid CIDR (`netip.ParsePrefix`). This is a
  host address with a prefix length, not a bare IP.
- `wireguard[].listenPort`: if set, 1–65535. It must be set on any node that a
  peer names in its `endpoint`, because nobody can dial a node with no listen
  port.
- `wireguard[].mtu`: if set, in the range 576–65535. If it is not set, the tool
  picks the default.
- `privateKey` / `privateKeyFrom`: set exactly one. Reject both, and reject
  neither. `SecretSource.validate` already covers the `...From` half (https-only
  URL, absolute file path, bounded `waitFor`). Validation checks an inline key
  for base64 shape and 32-byte length. So a truncated paste fails at validation,
  not at `wg` runtime.
- `nodeAddress`: at most one interface across the block can set it to true. If
  no interface sets it and the node joins over the overlay, that is a likely
  mistake. Validation warns in the "settings that do nothing" style that it
  already uses for a disabled `ha:` block. The reason is that a node whose
  registered address is not its overlay address looks healthy and is
  unreachable.
- `peers[].publicKey`: required, base64, 32 bytes. Validation rejects duplicate
  public keys within one interface. A duplicate is a copy-paste error that
  silently drops a peer.
- `peers[].endpoint`: if set, `host:port`. The host can be a name or an address,
  but an IP is recommended. A name must resolve before the network is up, and
  that network can depend on the overlay itself.
- `peers[].allowedIPs`: required, each a valid CIDR. Validation rejects
  overlapping `allowedIPs` across peers on one interface. WireGuard routes by
  longest match. Two peers that claim the same range are a routing ambiguity,
  not a configuration that an operator can intend.
- `peers[].persistentKeepalive`: if set, 1–65535 (seconds).
- If `wireguard:` is empty, the block does nothing and is silent. A `wireguard:`
  block on a node whose `role` never joins anything is not an error by itself,
  because an overlay can exist for reasons other than the cluster.

## Consequences

**A permanent, non-trivial schema surface.** The package doc in `types.go` says
it plainly: a key added here is permanent, ships to every node, and must be
supported forever, so add them reluctantly. An interface with peers is more
surface than any single key added so far, and this ADR spends that surface on
purpose. What the block is *not* keeps the scope small. It describes the view of
one node on an overlay, not a mesh. It creates no keys, distributes none, and
rotates none. It knows nothing of the other nodes beyond the peers that it is
given. Key material and topology management across a fleet are a control-plane
concern. Corium provisions nodes and does not manage them (a non-goal), and that
line keeps this block from growing into a control plane.

**A third deliberate `/etc` exception.** `wg-quick` reads `/etc/wireguard`, as
`sshd` reads `/etc/ssh/sshd_config.d` and cloud-init reads `/etc/cloud`. The
justification is the same as in ADR 5: it is the only place that the tool reads
from, so the configuration must live there or nothing reads it. This ADR names it
as an exception and does not slip it in silently. The file is written with mode
`0600` because it carries the private key. The sshd drop-in can be
world-readable, but this file cannot.

**`wireguard-tools` added to the image, present on every node, used by few.**
The package is not in the base image, and this was verified. So to accept this
decision is to add the package to the Corium `Containerfile`. It then ships
whether or not a node ever declares an interface. It is a small, dormant package
on machines that never use it, which is the cost of any package in an OS image.
This is on purpose the same bargain that ADR 5 made for `sshd`: present and inert
until something turns it on. The other choice is a package that is absent and
cannot be turned on, on an immutable OS. The module is already in the base
kernel. For each release, re-check the package addition and the
`wg-quick@.service` template that it brings with `rpm -q`, exactly as ADR 5
pinned the inherited `sshd`.

**`corium-agent` learns to configure a host interface.** This is the first host
*network interface* that Corium declares. Until now, host networking belonged to
cloud-init alone, and the `corium:` block described only cluster addressing
(pod/service CIDRs, the VRRP VIP). That boundary moves a little here. The ADR
names the move so that the next person does not have to discover it again.
`detectNodeIP` and the HA path both learn about the overlay interface. A
WireGuard overlay that carries a VRRP VIP works only over `unicastPeers`, because
multicast does not cross a point-to-point tunnel. `detectNodeIP` must select
from the overlay interface, not from the default route. These are real changes
to the most delicate code in the tree, and their tests are the price of the
feature.

**One window this record will not pretend is closed.** A peer `endpoint` given
as a hostname must resolve before the overlay is up. If DNS itself is reachable
only over the overlay, that is a cycle that the field cannot break. Validation
warns, and the documentation says to use addresses. The field does not make a
hostname endpoint safe. It only makes the failure clear.

## Alternatives considered

**Documentation only: a cloud-init recipe, no field.** This is the honest
baseline. It is rejected for three reasons that a recipe cannot fix. First, it
runs only where cloud-init runs, because `write_files` and `runcmd` are
cloud-init modules. Some on-premise or bare-metal nodes have no datasource. They
get their configuration from `/etc/corium/config.yaml` or the kernel command
line, the paths that `internal/source` exists to serve. On those nodes, the
recipe never runs at all, and those nodes are the exact population that an
overlay across sites and providers is for. Second, the recipe cannot make the
overlay address the address that the kubelet registers. `corium-agent` makes
that choice after cloud-init ran, and no kubelet-args escape hatch lets a
`runcmd` reach it. Third, the recipe cannot keep the private key out of
cleartext instance metadata. Everything else about a recipe works. A
`write_files` configuration and an enabled `wg-quick@.service` do bring an
interface up before k0s. But the three gaps turn a working demo into one of
three failures: a node that is unconfigured off-cloud, a node that is quietly
unreachable from across the overlay, or a cluster whose keys leak with its
metadata. ADR 3 used the same reasoning to reject documentation-only for
spare-disk RAID: the ordering problem is the one that silently breaks a node.
The recipe will still be documented as the escape hatch (decision 9). It is
still the right tool for an overlay that is *not* the cluster transport, on a
node that has cloud-init anyway.

**A bare node-IP override field plus the recipe.** This is tempting as the
smaller move: ship `wireguard-tools`, add a general `node.address` override so
that the kubelet registers the address it is given, and document the interface
as a recipe. It is rejected because it splits one invariant, "the node registers
its overlay address", across two places. The operator must keep the two in sync
by hand, and the agent cannot check that the interface actually carries the
address that the field names. It also leaves the private key in the
`write_files` of the recipe, unsolved. ADR 3 was able to split its problem cleanly
because there was a real seam: spare disks are declarable at first boot, and a
RAID root is impossible at first boot. Here there is no such seam. Interface
bring-up, node addressing and the secret all apply at first boot and after, all
sit close to the agent, and all involve the key. So a split cuts through the
middle of one problem, not between two problems.

**A NetworkManager keyfile connection instead of `wg-quick`.** NetworkManager
manages WireGuard natively, and it is the one renderer that the image already
runs. So it can create the interface without `wg-quick` at all. It is rejected as
the primary path for two reasons. First, it couples the feature to NM as the
renderer forever, but `wg-quick` is renderer-agnostic and survives a future
change of mind. Second, `wireguard-tools` ships present but inert in all cases,
because `wg` is wanted for diagnostics on a node whose overlay misbehaves. So
the package that NM saves is one that Corium keeps anyway, and avoiding
`wg-quick` gains little. The schema does not depend on which of the two creates
the interface. So this stays an implementation choice that the ADR does not need
to freeze.

**Managing the overlay's keys and mesh from the `corium:` block.** No. To create
and distribute keys, and to know the whole topology, is fleet management. Corium
provisions nodes and does not manage them. The block describes the peers of one
node and nothing more.
