Skip to content

feat: keel mesh invite and join --dry-run, the keel1: token (0048) - #72

Merged
marcos-mendez merged 2 commits into
mainfrom
feat/mesh-token
Oct 3, 2026
Merged

marcos-mendez merged 2 commits into
mainfrom
feat/mesh-token

Conversation

@marcos-mendez

@marcos-mendez marcos-mendez commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

First part of handbook decision 0048 (Keel-Linux/handbook#42), "joining the mesh with one command": the keel1: token, the address allocator, the pending-invite store, and keel mesh invite / keel mesh join --dry-run. The HTTPS listener, the real join, accept, create and remove come in the next PRs; the token and the store are laid out so they need no format change.

Seams under test

Written down before the tests; the tests exercise these and nothing behind them.

  1. Token encode/parse (keel.mesh.token): encode(Token) -> str and parse(text, now) -> Token, raising TokenError. Tested by round trip and by feeding parse damaged, truncated, unknown-version, inconsistent and expired tokens. repr(Token) and every error message are checked not to hold the secret.
  2. Allocator (keel.mesh.allocate): free_address(own, taken, randbelow) -> str, raising AllocationError when the prefix is full; randbelow is injected so the tests are deterministic.
  3. Invite store (keel.mesh.invites): reserve(root, now, make), find(root, id, now), consume(root, id, now, verify), expire(root, now), against a scratch root: modes, single use, expiry, no secret on disk.
  4. CLI output (keel mesh invite, keel mesh join --dry-run, through keel.cli.main): the printed line, the YAML change, the exit codes, the state written or not.

Not tested on their own: the byte layout, the lock, the checksum function; they are behind seam 1 and 3.

What it does

  • keel1: token (keel/mesh/token.py): fixed big-endian layout, base64url without padding, 4-byte SHA-256 checksum over prefix and fields; input over 1 KiB is refused before decoding. Carries the inviter's key, endpoints (IPv6 first, IPv4 optional, at least one), WireGuard port, HTTPS port, certificate fingerprint, the inviter's overlay address and prefix length, the assigned address, the mesh identity, the etcd state (and port when running), the invite id (derived from the secret and checked against it), the 256-bit secret and the expiry. 250 characters with one IPv6 endpoint, 256 with both. encode refuses what parse would refuse. The secret is repr=False and no error message holds it or the token text.
  • Allocator (keel/mesh/allocate.py): a random free address of the prefix (secrets), avoiding the node's own, the peers' allowed_ips (whole prefixes), pending invites and the subnet-router anycast; redraws on a collision, and after 16 searches from the last draw so a nearly full prefix still gives its last address and a full one is refused. Random so two members inviting before etcd exists almost never collide.
  • Invite store (keel/mesh/invites.py): /var/lib/keel/mesh/invites/<id>.json, 0600 in 0700 directories (re-tightened if widened), written through marker.write_private and under a flock as /var/lib/keel/network is. Holds the address, expiry, HTTPS port, the invite's certificate and TLS key, and hmac_key(secret), never the secret. reserve allocates and writes in one lock; consume takes a verify callback so the listener can check the HMAC and spend the invite in one step; a consumed invite is marked used and keeps its address reserved until it expires. Removing invites that expired while the machine was down, at boot, comes with the listener.
  • keel mesh invite: reads the key with wg pubkey, the endpoints from --endpoint or the static addresses of network.interfaces, makes the invite's certificate with openssl req -x509, makes the mesh identity once (/var/lib/keel/mesh/identity), reserves the address and prints the line alone on stdout. --port sets the HTTPS port (default 51820).
  • keel mesh join TOKEN|- --dry-run: prints the address and the peer entry as YAML, refuses a node in another mesh or at another address of it, and validates the spec as it would be after the join. Without --dry-run it exits 9.
  • New exit codes 23 MESH_TOKEN_INVALID and 24 MESH_REFUSED. keel 0.16.0. docs/mesh.md, linked from docs/spec.md, README.

The two LOW findings

validate_overlay now compares keys through wireguard.key_bytes/same_key (own key, and the duplicate-key check). While checking it I found that wg pubkey refuses a key whose last base64 character sets the two spare bits, which is_key accepted; key_bytes now refuses that spelling too, so validation no longer accepts a key wg-quick would reject.

Points of 0048 read one way here

  • The token carries the inviter's overlay address, which 0048's table does not list: the new node needs it for the inviter's allowed_ips, in the fallback above all.
  • The invite id is carried and also derived from the secret; parse checks that they agree.
  • 0048 says the invite file holds "the secret"; per this PR's brief it holds hmac_key(secret) only.
  • The overlay's optional ipv4_address is not carried; docs/mesh.md says so.

Tests

Full suite: 2448 passed, 4 skipped; coverage 99 %, keel/mesh 100 %. CI installs wireguard-tools, so the invite tests run the real wg and openssl.

navigator added 2 commits October 3, 2026 13:30
…efuses

Spec validation compared peer keys as text, for this node's own key
and for a key declared twice. Both now go through one helper,
wireguard.key_bytes and same_key. A key whose last base64 character
sets the two spare bits decodes to the same bytes in Python but is
refused by wg pubkey; key_bytes refuses that spelling too, so is_key
no longer takes a key wg-quick would reject.
First part of handbook decision 0048, joining the mesh with one
command. keel mesh invite prints keel mesh join keel1:<token>, valid
one hour and once, and reserves the new node's overlay address, a
random free one of the prefix (secrets), so two members inviting at
once before etcd exists almost never collide, in a pending invite
under /var/lib/keel/mesh (0700 directories, 0600 files, atomic
writes, one lock). The invite keeps an HMAC key derived from the
token's secret, never the secret, and the invite's own certificate,
whose SHA-256 the token carries for pinning. Consuming an invite marks
it used and keeps its address reserved until it expires.

keel mesh join TOKEN|- --dry-run checks the token (length, prefix,
checksum, layout, values, expiry) and prints the address and peer
entry it makes in the spec, validated as the spec would be after the
join; it applies nothing. The token already carries the HTTPS port,
the mesh identity and the etcd state, so the listener, the join,
accept, create and remove need no format change. Removing expired
invites at boot comes with the listener.

New exit codes 23 and 24. docs/mesh.md. keel 0.16.0.
@marcos-mendez
marcos-mendez merged commit 55bdd6c into main Oct 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant