The mesh — nodes, meshauthority and its authentication
Every sklet node holds a WireGuard address handed out by meshauthority; a signed request under an admitted key is the only way in. This is the exact contract, as the code enforces it.
What the mesh is
Every sklet node carries a WireGuard interface, wg0, with one address in
10.90.0.0/24. A service running in a space on node A is reachable from a
space on node B at <service>.<space>.internal, which resolves to A's mesh
address. The front uses the same path to proxy a public host to the origin
that runs it (ADR-021). Nothing else crosses the mesh: image pulls, telemetry
and package downloads leave through the node's own egress.
One process, meshauthority, is the registry: who holds which WireGuard
public key, which mesh address, which dialable endpoint, and which
(space, service) pairs. It runs on the front, listens on :4300 over TLS,
and is the control plane only. Once peers are configured, traffic is
peer-to-peer WireGuard and keeps flowing while the authority is down.
| Piece | Where | Code |
|---|---|---|
meshauthority | one instance, on the front, space meshauthority | apps/meshauthority |
mesh identity, wg0, registration client | every node, in the sklet agent process | apps/sklp/spacemanager/mesh |
network controller: DNS for .internal, cross-node relay, peer cache | every node, in the agent | apps/sklp/spacemanager/netctrl |
| control API relay for private origins | the front's agent | apps/sklp/spacemanager/control/relay.go |
Design records: ADR-006 (the mesh), ADR-008 (cross-host relay), ADR-019 (private origins behind the front), ADR-021 (front routes), ADR-024 (admission, TLS, contested routes).
Address plan
| Range | Owner | Purpose |
|---|---|---|
10.73.0.0/24 (example) | the cloud private network | node-to-node: WireGuard UDP, the authority, the control API relay |
10.90.0.0/24 | meshauthority | mesh addresses, one /32 per node; .0 and .1 are never handed out |
10.88.0.0/24 | each space, in its own netns | containers; never routable across nodes or from the host |
A node's WireGuard endpoint is <private ip>:51820. The listen port is fixed
in the binary (listenPort in wg_linux.go) and the security group opens it
between nodes only.
A node's identity
sklet agent creates its mesh identity on first start and never changes it
(mesh.EnsureIdentity). It lives in ~/.config/sklp/mesh/, 0600, outside
the cache so sklp clean cannot remove it:
| File | Content | Role |
|---|---|---|
node-id | 16 random bytes, hex | the peer's ID in the registry and the label in <node-id>.nodes.<zone> |
wg.key | X25519 private key | WireGuard; its public key is what peers put in wg0 |
sign.key | Ed25519 private key (64 bytes hex, public key = last 32) | signs every request to the authority; this is the node's identity for admission |
peers.json | the last peer list applied to wg0 and the node's own mesh address | brings wg0 up when the authority is unreachable at boot |
Two keys on purpose: WireGuard's is a key-agreement key (X25519) and must not double as a signature key.
Wiping the directory makes a new node: new ID, new keys, new mesh address, and the old ID stays in the registry as a silent peer until someone removes it (see Rebuilding a node).
The registry
meshauthority keeps the peers in memory and persists them to one JSON file
(MESH_DATA_FILE, atomic write + rename), on a volume of the front. Every
node re-registers its full state every 5 seconds, so the file is a cache of
live reality; the only durable value in it is the address allocation.
A peer record:
| Field | Set by | Meaning |
|---|---|---|
id | the node | its node-id |
pubkey | the node | WireGuard public key, base64 |
sign_key | the authority, from the request signature | Ed25519 public key, hex; bound at first registration |
private_ip | the authority | mesh address, allocated once, kept across restarts |
endpoint | the node | <private ip>:51820, from --mesh-endpoint |
spaces | the node | names of the spaces it runs |
services | the node | (space, service, port) triples its controller serves |
last_seen_at | the authority | last successful register or heartbeat |
retired keeps, for every removed ID, its signing key and its old mesh
address: a removed ID can only come back under the same key, and gets its
address back if it is still free.
Endpoints
All /v1/peers routes require Authorization: Bearer <MESH_TOKEN>
(constant-time compare, 401 otherwise). GET /health needs nothing.
| Method and path | Signed | Effect |
|---|---|---|
POST /v1/peers/register | yes | upsert this node: keys, endpoint, spaces, services (whole-list replace); returns the node's record and the full peer list |
POST /v1/peers/{id}/heartbeat | yes | refresh last_seen_at only |
GET /v1/peers | no | list admitted peers, sorted by ID |
DELETE /v1/peers/{id} | yes | forget a peer silent for 2 minutes; the signer must be a live admitted member |
Request signing
Every signed request carries three headers:
| Header | Value |
|---|---|
X-Mesh-Sign-Key | the node's Ed25519 public key, hex |
X-Mesh-Timestamp | Unix seconds |
X-Mesh-Signature | Ed25519 signature, hex, over the payload below |
The payload is four lines joined by \n:
<METHOD>
<path> e.g. /v1/peers/register
<timestamp>
<hex sha256(body)> body empty => sha256 of the empty stringMethod and path pin the signature to one operation; the timestamp is
accepted within 5 minutes of the authority's clock (MaxClockSkew); the body
hash covers the published services, so they cannot be rewritten on the way.
The payload is built identically on both sides
(mesh.SigningPayload in the authority, signingPayload in the client),
duplicated rather than imported because the two are separate Go modules;
tests on both sides pin it byte for byte.
Order of checks on a write: bearer token, then signature (401), then admission and identity (403).
Answers a node can get
| Status | Error | Cause |
|---|---|---|
| 401 | missing/invalid signature, timestamp outside window | clock drift over 5 minutes, or a client without a signing key |
| 403 | signing key is not admitted to this mesh | the key is not in MESH_ADMITTED_KEYS; logged with the key |
| 403 | peer id is bound to another signing key | someone is registering or heartbeating an existing ID with a different key |
| 409 | signing key already belongs to another peer <id> | one admitted key trying to mint a second peer |
404 / 409 on DELETE | peer not found / peer is still alive | wrong ID, or the target registered within the last 2 minutes |
403 on DELETE | signing key belongs to no registered peer | the signer is not itself a live admitted peer |
Admission and revocation
MESH_ADMITTED_KEYS is the comma-separated list of admitted signing public
keys (hex, case-insensitive). It is the trust list of the mesh:
- a registration under any other key is refused with 403;
- a peer already registered whose key leaves the list disappears from
GET /v1/peersand from every node'swg0on their next refresh, and can no longer heartbeat, register or delete anyone; - an empty list keeps the mesh open to any holder of the token and prints
WARNING: MESH_ADMITTED_KEYS is emptyat startup. Never run production that way.
The list lives in .sklp/meshauthority/deploy.yaml, so changing it is a
redeploy of the authority (one rolling restart, the data path is unaffected).
The keys are public; only the token and the TLS key are secrets.
Identity is trust on first use on top of admission: the first registration of an ID binds its signing key, and every later request for that ID must be signed by it. Holding the token is therefore never enough to take over a node, and holding an admitted key is never enough to take over another node's ID.
The token and TLS
MESH_TOKEN is one shared secret proving membership of the mesh, nothing
more; identity comes from the signing key. Every node reads it from
SKLP_MESH_TOKEN (rendered into ~/.config/sklp/node.env, 0600) and
removes it from its environment once read, so space workers never inherit it.
The authority refuses to start without MESH_TLS_CERT
(MESH_ALLOW_PLAINTEXT=1 exists for local tests only). The certificate is
self-signed, generated once with MESH_TLS_HOSTS as SANs, stored on the data
volume next to the registry, and reused across restarts so the pin stays
valid. At startup the authority prints
certificate fingerprint (pin this on every peer): <sha256 hex>.
Every node pins that fingerprint: sklet host configure refuses to render a
unit unless the authority URL is https:// and the fingerprint is a full
SHA-256 (mesh.CheckAuthority). The client verifies the presented leaf
against the pin and nothing else; there is no CA.
Data plane
On every start the agent joins before any space is launched
(mesh.Join):
EnsureIdentityloads or creates the files above.Registerwith the pinned client. On success:wg0is created with the returned address/24, its full peer set is replaced (ReplacePeers), each peer getsAllowedIPs = <mesh ip>/32and its endpoint, and the list is written topeers.json.- On failure: if
peers.jsonexists,wg0comes up from it and the agent logsmeshauthority unreachable, brought wg0 up from the last known peer list; the node serves traffic in degraded mode and keeps retrying. A node that never joined stays out until the authority answers.
Then the network controller's reconciler runs every 5 seconds: it calls
Register with the node's current spaces and the (space, service, port)
triples it serves, receives the peer list, replaces wg0's peers, and
refreshes its peer cache. There is no separate heartbeat loop; a full
register is the heartbeat.
The peer cache (netctrl.PeerCache):
- ignores peers not seen for 30 seconds (
peerStaleAfter); - maps
<space>.<service>to the mesh address of the one peer publishing it; - when two live peers publish the same pair it routes nothing for that
pair and logs
netctrl: <space>.<service> is published by peers A and B; not routing it until one withdraws(ADR-024: contested routes go dark, the front answers 503); - maps a peer ID to its endpoint host, which the front's relay uses.
Cross-node resolution: a container asks its space DNS for
<service>.<space>.internal; a name the node does not serve itself is
answered with the remote node's mesh address. The controller binds its HTTP
proxy and TCP listeners on the node's mesh address as well as on the space
side, so the remote container connects to <mesh ip>:80 (HTTP) or the
declared TCP port over wg0, and the local controller hands the stream to
the container. The controller accepts any mesh peer on those listeners: the
mesh proves the peer is an admitted node, not which space the request comes
from (ADR-024 consequence, ADR-023 must not trust an identity header for
having arrived over wg0).
The control API relay
Private origins have no public address (ADR-019). Their control API
(:6464, TLS with a per-node self-signed certificate and a per-node bearer
token under ~/.config/sklp/control/) is bound on the private interface only
(privateOriginListen), and they advertise themselves as
https://<node-id>.<relay domain>:6464 (advertise_via in node.yaml).
The front runs --node-relay-domain <relay domain> and a wildcard DNS
record *.<relay domain> points at its public address. Its control listener
peeks the TLS ClientHello of every connection: an SNI of the form
<node-id>.<relay domain> is spliced, bytes untouched, to that peer's
control API on its private address, looked up in the peer cache by node ID.
Any other SNI is served by the front's own control API.
Consequences:
- the origin's certificate pin and token stay end to end; the front cannot read or inject anything;
- a node unknown to the mesh, stale for 30 seconds, or not admitted is not relayed: the front closes the connection;
- everything the operator or the core does to a private node goes through
this path:
sklp deploy apply|ls|logs|exec|restart, git reconcile, image rolls, restore drills,sklp deploy mesh, and Data connections of typespace(POST /v1/spaces/<space>/dial/<service>, a raw TCP stream to the service's host-side port); - this traffic uses the private network, not
wg0.
Operations
Read the mesh
sklp deploy mesh ls # via the current context's nodeColumns: ID, mesh IP, endpoint, last seen, number of services, spaces, sign
key. The node relays GET /v1/peers with its own token; the operator never
needs MESH_TOKEN.
On a node: journalctl --user -u sklet-agent | grep -E 'mesh|netctrl'. On
the front, the authority's log names every refused registration:
refused <id>: signing key <key> is not in MESH_ADMITTED_KEYS.
Add a node
-
Provision it on the private network and configure
node.yaml:mesh: authority_url: https://10.73.0.10:4300 cert_fingerprint: <sha256 printed by meshauthority> endpoint: 10.73.0.60:51820 advertise_via: nodes.front.<zone>and
SKLP_MESH_TOKEN=<token>innode.env, thensklet host configure. -
The agent starts, generates its identity and is refused with 403. Read its signing key in the authority's log or in
~/.config/sklp/mesh/sign.keyon the node (public key = last 64 hex characters). -
Append the key to
MESH_ADMITTED_KEYSin.sklp/meshauthority/deploy.yamland redeploy the authority from a cleanmain(sklp deploy context use mesh-front && sklp deploy apply meshauthority). -
Within 5 seconds the node registers, gets a mesh address, and appears in
sklp deploy mesh lswith its sign key.
Revoke a node
Remove its key from MESH_ADMITTED_KEYS and redeploy the authority. The
node vanishes from the peer list; every other node drops it from wg0 at
its next refresh (5 seconds); its routes stop resolving; the front no longer
relays its control API. Its ID stays bound to that key, so nobody else can
register it.
Rebuild a node
A rebuilt VM has a new identity. The old ID stays in the registry with
last_seen_at frozen. Once it has been silent for 2 minutes:
sklp deploy mesh rm <old id>The node relays a signed DELETE; the old address returns to the pool
(reserved first for a return of the same key). Do not remove a live peer:
the authority refuses it, and it would re-register at once anyway.
Then admit the new key exactly as for a new node. If the volume was kept
(~/.config/sklp/mesh/ intact), nothing changes: same ID, same key, same
address.
Rotate the token
Change MESH_TOKEN in the skalpai project secrets (the authority reads it
from its stack config), redeploy the authority, then update node.env on
every node and restart each agent. Between the two, nodes fail to register
and keep serving from peers.json; the front goes last, because a
reconfigured node drops peers it cannot see and the front is the one
everyone must still reach.
Rotate the TLS certificate
Delete tls.crt/tls.key on the authority's volume and restart it: a new
certificate is generated and its fingerprint printed. Until each node's
cert_fingerprint is updated (sklet host configure re-renders the unit
and restarts the agent), that node cannot register and runs from
peers.json. Same order as above: origins first, the front last.
Move the authority to TLS from plaintext
Documented in ADR-024 and done on 2026-09-29: authority on TLS with the
ready: tcp probe (its HTTP probe cannot speak TLS), then every origin
(https:// URL and fingerprint), then the front, then the binaries.
Threat model in one table
| Attacker holds | Can | Cannot |
|---|---|---|
| the network path | see encrypted WireGuard and TLS bytes | read the token, the registry, or any application traffic |
MESH_TOKEN only | call GET /v1/peers | register (no admitted key): 403 |
| an admitted node, fully (root) | reach other nodes' services through wg0 on the ports their controllers publish; publish a (space, service) whose real node is down and receive its traffic | take over a live node's ID (bound key), mint a second peer (409), reach a private node's control API without its token and pin, make the front route a contested pair (it goes dark) |
| the operator's laptop config | everything the node tokens allow, per context | join the mesh: a laptop has no admitted key |
The residual risk, accepted in ADR-024, is the compromised admitted node publishing a space whose real node is down. Closing it means declaring which node may publish which space; that is not built.
Environment and flags reference
meshauthority:
| Variable | Required | Meaning |
|---|---|---|
MESH_TOKEN | yes | the shared bearer token |
MESH_TLS_CERT | yes (or MESH_ALLOW_PLAINTEXT=1) | path of the self-signed certificate; created if absent |
MESH_TLS_KEY | no | key path, default: the cert path with .key |
MESH_TLS_HOSTS | no | comma-separated SANs for the generated certificate |
MESH_ADMITTED_KEYS | production: yes | comma-separated admitted signing keys |
MESH_DATA_FILE | no | registry path, default ~/.local/state/meshauthority/peers.json |
PORT | no | default 4300 |
sklet agent (rendered by sklet host configure from node.yaml):
| Flag / env | node.yaml key | Meaning |
|---|---|---|
--mesh-authority-url | mesh.authority_url | https:// URL of the authority |
--mesh-cert-fingerprint | mesh.cert_fingerprint | SHA-256 of its certificate, hex |
--mesh-endpoint | mesh.endpoint | this node's <private ip>:51820 |
SKLP_MESH_TOKEN (env) | node.env | the token; unset once read |
--advertise-via | advertise_via | private origin: advertise <node-id>.<domain> as its server |
--node-relay-domain | node_relay_domain | front: relay <node-id>.<domain> connections to that peer |
--advertise | advertise | public node: its own address; exclusive with advertise_via |