Repository navigation
feat: keel mesh create, the invite listener, join, accept and status (0048) - #75
Merged
Merged
Conversation
marcos-mendez
force-pushed
the
feat/mesh-join
branch
2 times, most recently
from
October 3, 2026 15:54
1146f46 to
592f6a6
Compare
…(0048) The second part of handbook decision 0048, without etcd. - keel mesh create makes the mesh on its first node and confirms its own overlay once the route check finds the gateways and the operator's SSH client still on the uplink (0048 amending 0018). - keel mesh invite starts a root helper, keel-mesh-invite@<id>, which starts the listener that faces the network as keel-mesh-listen@<id>: DynamicUser, an empty capability bounding set, NoNewPrivileges, ProtectSystem=strict and the rest of a service sandbox. The listener binds the bridge socket (0600) in its own 0700 RuntimeDirectory; the helper connects, checks SO_PEERCRED against the unit's MainPID, skips any other peer, and hands over the invite's certificate and TLS key as memfds. The HMAC key never leaves the root side, and the listener refuses to run with any capability. The port, TCP 51820 by default, is opened only through keel-firewall's new mesh_invites set, for the expiry plus the confirmation window. - The listener reads each request under a 15 s deadline, with 4 slots and 2 more kept for the overlay, one connection per source, capped headers and body and a per source refusal limit; it checks shape, invite id, time and nonce and forwards the bytes. The root side's Admitter verifies the HMAC (constant time) and the rest again, cancels the invite after 5 requests that name it and fail its HMAC, refuses a key it already knows, consumes the invite once under the lock, writes the peer and applies under the window. It takes the confirmation only with a WireGuard handshake from the new peer's key since the join, never on the listener's word. - keel mesh join pins the token's fingerprint, sends its key and endpoint, writes its own spec and applies it, and confirms both windows through the mesh session, checking each answer's nonce and invite id; with the port unreachable it applies its side and prints keel mesh accept keel1a:<answer>, which accept takes on the inviter. The printed line feeds the token on standard input. - keel mesh status: peers, handshakes, pending invites, no secret. - keel.network.confirm gains the two sources 0048 adds, each for the overlay change keel mesh made and no other. Spec writes go through keel.mesh.node.Node, validated whole and written with keel.network.marker.write_private (comments dropped, mode 0600). Tested at those seams, with real TLS on the loopback, the bridge in one process, and end to end across three network namespaces with the real wg-quick and the listener under setpriv without capabilities, on clean links and on 250 ms +-25 ms links with 2% loss (tests/test_mesh_netns.py), also in a trixie LXC job.
marcos-mendez
force-pushed
the
feat/mesh-join
branch
from
October 3, 2026 16:10
592f6a6 to
3fc9220
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The second part of handbook decision 0048, "joining the mesh with one command", without etcd. The first part is keel#72.
Seams (written down before the tests)
keel.mesh.admit.Admitter.forward(path, signature, body, local, peer) -> Response(tests/test_mesh_admit.py). Unprivileged front:keel.mesh.listener.Listener.handle(method, path, signature, body, local, peer)(tests/test_mesh_listener.py).bound()/run()only put the front behind TLS (tests/test_mesh_channel.py, real TLS on the loopback).keel.mesh.bridgejoins the two over a unix socket (tests/test_mesh_bridge.py, the whole path in one process).keel.cli.main→ thin handlers inkeel.mesh.commands(root check, token or line read, liveNodebuilt) → the flowsjoining.run,inviting.serve_invite,inviting.accept,create.create,status.lines, each taking its outside world as fields (post,sleep,route_dev,serve,run,clock) (tests/test_mesh_cli*.py,test_mesh_joining.py,test_mesh_inviting.py,test_mesh_create_status.py).keel.mesh.node.Node: read the spec as keel reads it, validate the whole document, write it withkeel.network.marker.write_private(temp file, fsync, rename, 0600), apply withapply_system(..., skip_uplink=True, defer_certificate=True), read back the marker apply armed, and confirm throughkeel.network.confirm. The flow tests use realNodes on scratch roots, with onlyapplyreplaced by one that arms the marker, so the real confirmation logic decides.What it does
keel mesh create: random ULA /64, this node at::1, identity, apply under the 0018 window, then self-confirmation (session.SELF) after the route check (gateways plus the operator's SSH client).keel mesh invitenow startskeel-mesh-invite@<id>(systemd-run --collect -p RuntimeMaxSec=…, runningkeel mesh serve <id>), opens the port only through keel-firewall's newmesh_invitesset (nft add element … timeout, expiry + 180 s like the helper) and otherwise says the fallback applies. One pending invite per port. It printskeel mesh join - <<< keel1:…, so the token is never in argv.keel-mesh-invite@<id>(keel mesh serve) binds/run/keel/mesh/<id>.sockand startskeel-mesh-listen@<id>(keel mesh listen) withDynamicUser=yes,CapabilityBoundingSet=/AmbientCapabilities=empty,NoNewPrivileges,PrivateTmp,PrivateDevices,ProtectSystem=strict,ProtectHome, the kernel/clock/hostname/cgroup protections,RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX,RestrictNamespaces,SystemCallFilter=@system-service,MemoryMax=64M,TasksMax=32,LimitNOFILE=64./run/keel/mesh-listen-<id>(0700, the dynamic user's), accepts only root, and removes the socket once connected. The helper connects, checks SO_PEERCRED against the unit's MainPID (systemctl show -p MainPID) and not root, and skips any other peer. It sends the parameters (no key) and the cert and TLS key as two memfds over SCM_RIGHTS.Admitterverifies the HMAC, counts forgeries (5 cancel the invite and tell the listener to stop), refuses a key it already knows, consumes the invite once, writes the spec and applies.wg show <if> latest-handshakeshandshake from the joiner's key since the join (waited for up to 10 s), plus the HMAC-verified confirm request. The listener's local/peer report is logged and decides nothing. Tests include a lying listener: a forged join with a chosen key, and a confirm with the right addresses but no handshake; both are refused.acceptuses the same split, bound to the overlay address.keel mesh join - <<< TOKEN(the lineinviteprints; the token as an argument still works, with a warning): route check to the endpoint (otherwise names the 0024 rendezvous point, applies nothing), pin, request, then a signed answer that must echo nonce, id, key and address. It writes its own spec, applies, opens the mesh session over the overlay, and confirms both windows (session.MESH). On no TCP answer it applies its own side (window = time left, ≤15 min) and printskeel mesh accept keel1a:…. With no endpoint of its own it refuses (both behind NAT).keel mesh accept LINE|-: HMAC and reserved address checked before consuming. It stops the invite's helper, adds the peer withpersistent_keepalive 25, applies, and runs the same helper and unprivileged listener on its overlay address in the foreground. A peer of the same key is refused, never replaced. The confirm answer's nonce and invite id are checked, as the join answer's are.keel mesh status: peers, endpoints, last handshakes (wg showpublic-key/endpoints/latest-handshakes only), pending invites; no secret.keel.network.confirm: the two 0048 sources, each confirming only the overlay change keel mesh made, identified by the marker's boot id and up time. They never confirm an uplink change.Protocol messages
Signature header
X-Keel-Mesh-Signature= hex HMAC-SHA256(invite HMAC key,"keel mesh 1\n" + method + "\n" + path + "\n" + body). Answers are signed with methodANSWER. Refusals are unsigned{"error": reason}.POST /v1/join(uplink):invite_id, public_key, endpoint ("[addr]:port" | null), address (reserved, with length), nonce (16 B hex), timeinvite_id, nonce, public_key, address (inviter's, with length), peers [{public_key, endpoint|null, address}], etcd ("none"), windowPOST /v1/confirm(overlay):invite_id, public_key, nonce, timeinvite_id, nonce, confirmed, detailkeel1a:fallback line (binary, base64url):flags, public key, [endpoint + port], address + length, invite id, time, HMAC-SHA256, checksum.Where I interpreted 0048
/etc/sslof the machine.RuntimeMaxSec= time to expiry + 180 s (one default window + 60), so a join accepted just before the expiry can still be confirmed. Joins after the expiry are refused by the store.apply --system-only --skip-uplinkwith--defer-certificate: an unattended listener should not move the uplink or ask Let's Encrypt.peersare sent, but the joiner writes only the inviter. Announcing the new node to the other members ("cloud simple grows through the inviter") is not in this PR; the summary says so.keel spec set(keel#69) will unify the writers.ipv4_address) is not carried, as in keel#72.Not here (follow-ups)
join -), after this is merged.keel mesh remove(listingsharedsecrets, no rotation),keel mesh invites/invite --cancel, the boot unit for invites that expired while down,inspectreporting pending invites.Known edges for the security review
keel mesh statusshows it.mesh_invitesset. A reload during an invite closes its port, and the fallback covers that.--portunder 1024: it has no capability.Tests
keel/meshat 100% line and branch coverage; whole package 99% (gate 95).keel-mesh-listen@unit ran as dynamic uid 62869 with CapEff=CapBnd=0, NoNewPrivs=1, Seccomp=2. Its RuntimeDirectory was 0700 and the dynamic user's, the MainPID check trusted it, the socket was removed after the helper connected, a TLS join went through the bridge, and the following requests were refused.tests/test_mesh_netns.py: three network namespaces joined by veths. Realwg-quick, real TLS, the real helper/bridge/listener (the listener a separate process undersetprivwith all capabilities dropped, reporting CapEff 0), join, accept and confirm logic. B joins through the listener and C through the fallback; both ping A over the overlay with nokeel network confirmtyped. The spent invite is refused, and A's journal holds no secret. Runs twice: on clean links, and with netem at 250 ms ±25 ms and 2% loss on every link (about 17 s; passed 5/5 locally). The join's connect timeout was raised to 10 s so that two lost SYNs don't trigger the fallback. Runs in the hosted job (sudo -n unshare -n) and in a newmesh / trixiejob on keel-lxc-1 (needs thewireguardmodule loaded on the runner host).