Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ in [docs/apply.md](docs/apply.md).
| `keel network revert` | Put back the interfaces file a pending network change replaced; what the revert timer runs. `--boot` restores the file only, for the boot unit |
| `keel network wireguard key` | Print this node's WireGuard public key, making the key pair first when there is none (root, live system only); the private key is never printed (decision 0020, docs/spec.md) |
| `keel network wireguard suggest-address` | Print a random unique local IPv6 address with its /64, for the first node of an overlay |
| `keel mesh invite` | Print the `keel mesh join keel1:<token>` line a new node runs to join this node's WireGuard mesh, valid one hour and once, and reserve the new node's overlay address (decision 0048, [docs/mesh.md](docs/mesh.md)) |
| `keel mesh join` | With `--dry-run`, check a `keel1:` token and print the address and the peer it makes in this node's spec, applying nothing; the join itself comes later ([docs/mesh.md](docs/mesh.md)) |
| `keel notify` | What monit runs when a check of the `monitor` section fails, lasts or recovers: sends one message, saying what happened and what to do, to every channel the section declares (email, Telegram, ntfy, a webhook). It reads the channels from `/etc/keel/monitor.json`, which `apply --system` writes as root, never from the spec, reads the tokens from their secret files and prints no URL (decision 0021, docs/apply.md) |
| `keel inspect` | Write a spec from the running machine, or from an offline root, and report every field with its source or why it was not inferred; secrets are never read (brief sections 5.2 and 7) |
| `keel diff` | Report drift between the spec and the running machine, or an offline root, field by field, through the same collector `inspect` uses; secrets are never compared and their files need not exist, nothing is written (brief section 5.2) |
Expand Down Expand Up @@ -138,14 +140,16 @@ Defined in one place, `keel/exits.py`, and reproduced here.
| 12 | `ASSEMBLE_FAILED` | The rootfs is not empty or cannot be created, or `tar` or `zstd` failed while extracting or packing |
| 13 | `INSPECT_INCOMPLETE` | `inspect` wrote the spec, but a required field (hostname, fqdn, interfaces, alerts, updates) could not be inferred; or `diff` found no drift, but a declared field could not be observed. The report says which and why |
| 14 | `DRIFT_FOUND` | `diff` found at least one declared field whose observed value differs. Drift wins over unobserved fields, so a report with both exits 14 |
| 15 | `APPLY_NEEDS_ROOT` | The system phase (`--system`, `--system-only`) on the live system was run by a user other than root; nothing was written, not even the conf |
| 15 | `APPLY_NEEDS_ROOT` | The system phase (`--system`, `--system-only`) on the live system was run by a user other than root; nothing was written, not even the conf. Also `keel network wireguard key` making a key, and `keel mesh invite`, on the live system |
| 16 | `APPLY_FAILED` | The system phase could not make at least one change; the output names it. The conf was written, where the run writes one, and every other change was made, so the run can be repeated |
| 17 | `CHANNEL_INVALID` | A channel pointer, or the record of the one this instance follows, does not parse or fails validation. Also a pointer signed in the future, one claiming more than 30 days of life, and a revision's archived pointer naming another revision |
| 18 | `CHANNEL_UNVERIFIED` | A channel pointer is not signed by a key that may move a channel, or no keyring was given to check it against. A key that is revoked or expired is refused here: gpgv exits 0 for both and still prints `VALIDSIG`, so `GOODSIG` is what is required |
| 19 | `CHANNEL_EXPIRED` | A channel pointer is past its expiry. A mirror that is stale, broken or hostile holds an appliance on an old release by not updating, so this is an error and never a warning |
| 20 | `CHANNEL_ROLLBACK` | A channel pointer names an earlier revision than the one this instance is on; `--allow-rollback`, or naming the release and revision, is how going back is asked for |
| 21 | `NETWORK_NOT_CONFIRMED` | `keel network confirm` refused: no change is waiting, or it was not run from a new session over the new configuration, or a console (docs/apply.md) |
| 22 | `NOTIFY_FAILED` | `keel notify` reached no channel: `/etc/keel/monitor.json` is missing or refused, declares none, or every one failed; the message then went to syslog (user.crit) and root's mailbox. One channel that takes the message is success; the others' failures are printed |
| 23 | `MESH_TOKEN_INVALID` | `keel mesh join` could not use the token: not a `keel1:` token, mistyped or truncated (its checksum), of a later format, inconsistent, or expired. The message never holds the token or its secret |
| 24 | `MESH_REFUSED` | `keel mesh` refused: no overlay to invite into, no key yet, no endpoint, no free address in the prefix; or this node is in another mesh, at another address of it, or the change would make a spec validation refuses |

Two rules that callers depend on:

Expand Down
21 changes: 21 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
keel (0.16.0) unstable; urgency=medium

* keel mesh invite and keel mesh join --dry-run, the first part of
handbook decision 0048 (joining the mesh with one command). invite
prints the keel mesh join keel1:<token> line, valid one hour and
once, and reserves the new node's overlay address, a random free
one of the prefix, in a pending invite under /var/lib/keel/mesh
(0700, 0600) that keeps an HMAC key derived from the token's
secret, never the secret; a consumed invite keeps its address
until it expires. join
--dry-run checks the token (prefix, checksum, layout, expiry) and
prints the address and the peer it makes in the spec; it applies
nothing yet. The listener, the join itself, accept, create and
remove come next. docs/mesh.md. New exit codes 23 and 24.
* WireGuard keys are compared by their 32 bytes, through one helper,
in the peer checks of spec validation; and a key spelled with the
two spare bits of its last character set, which wg refuses, is no
longer taken for a key.

-- Marcos Mendez <mendez.foto@gmail.com> Sat, 03 Oct 2026 20:00:00 +0000

keel (0.15.4) unstable; urgency=medium

* spec validation refuses a peer of network.overlay.wireguard that holds
Expand Down
220 changes: 220 additions & 0 deletions docs/mesh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# keel mesh

Joining a node to the WireGuard mesh takes one command (handbook decision
0048). On a node already in the mesh, `keel mesh invite` prints one line:

```
keel mesh join keel1:<token>
```

and that line, run on the new node, joins it. The token carries every
value the new node would otherwise copy by hand from
[docs/spec.md](spec.md), "overlay": the inviter's public key, its
endpoint and port, the overlay prefix and the address reserved for the
new node. It is valid for one hour and can be used once.

What a join writes is the same `network.overlay.wireguard` fields an
operator writes by hand, `address` and a peer, converged by the same
`apply --system` under the same confirmation window. There is no `mesh`
section in the spec, and a mesh built by joins and one typed by hand are
the same spec.

**What exists in this version:** the token, the address an invite
reserves, the pending invites, `keel mesh invite`, and `keel mesh join
--dry-run`, which prints the change a token makes and applies nothing.
The listener that answers a join, the join itself, the fallback command
`keel mesh accept`, `keel mesh create` for a node with no overlay, and
`keel mesh remove` come next; the token and the invite file already
carry what they need.

## keel mesh invite

```
keel mesh invite [--endpoint ADDRESS]... [--port PORT]
```

Root, on a node whose spec declares `network.overlay.wireguard` and whose
key file exists (apply makes it the first time it converges the overlay).
It:

1. reads this node's public key from its private key file with `wg
pubkey`, as `keel network wireguard key` does; the key is not made
here;
2. takes the endpoints the new node will reach this one at: the
`--endpoint` addresses (one per family, for a port forward say), or
else the static addresses `network.interfaces` declares, IPv6 first;
3. makes a key pair and a self signed certificate for this invite only
(`openssl req -x509`, P-256), whose SHA-256 fingerprint the token
carries for the new node to pin;
4. makes the mesh's identity if this node has none (below);
5. under the mesh's lock, removes the expired invites, reserves a free
address (below) and writes the pending invite;
6. prints the `keel mesh join` line, alone, on standard output; what it
reserved and until when go to standard error, never the token.

`--port` is the TCP port the join request will go to, 51820 by default,
the number of WireGuard's UDP port, so a provider's firewall needs one
number for both.

No listener answers the invite yet in this version, so the line can be
checked on the new node with `keel mesh join --dry-run` and not used to
join.

### The address

A random address of the inviter's overlay prefix, drawn with Python's
`secrets`, that is not its own, not inside any peer's `allowed_ips`, and
not reserved by another pending invite. Random rather than the next
free one: until etcd exists, a member does not know the invites another
member has pending, and two members inviting at the same time would both
hand out `::2`; two draws in a `/64` practically never meet. A draw that
lands on a taken address is drawn again; after 16 taken draws in a row
the prefix is nearly full and is searched from the last draw, so its
last free address is still found and a full prefix is refused. The
prefix's own address (`::`, the subnet router anycast) is never given.

## keel mesh join --dry-run

```
keel mesh join TOKEN --dry-run
keel mesh join - --dry-run # the token on standard input
```

Reads the token, checks it whole (below), and prints on standard output
the fields of `network.overlay.wireguard` the join would set in this
node's spec:

```yaml
network:
overlay:
wireguard:
address: fd00:6b65:1::3/64
peers:
- public_key: nb9/izIukqWXM7gnBpe7hki4jKZZChWOW1wEfONn82E=
endpoint: '[2001:db8:1::10]:51820'
allowed_ips:
- fd00:6b65:1::1/128
```

The inviter is the peer: its key, its endpoint (IPv6 when it has one),
and its overlay address as a `/128`. The spec is read and nothing is
written or applied. It refuses a node whose overlay is on another prefix
(another mesh) or at another address of this one (a node joins once),
and a change `spec validate` would refuse once made, such as an overlay
prefix that overlaps this node's uplink, naming the error. Without
`--dry-run`, `join` applies nothing in this version and exits 9.

The overlay's optional `ipv4_address` is not carried: a join gives the
new node an IPv6 overlay address and routes the inviter's IPv6 address
only. A mesh that also uses IPv4 on the overlay adds those addresses by
hand, as before.

`-` reads the token from standard input, which keeps it out of the
process list and the shell's history; confconsole will use it.

## The token

`keel1:` followed by the base64url encoding, without padding, of the
fields below in this order, big endian, and a checksum: the first four
bytes of the SHA-256 of the prefix and the fields. A typical invite,
with one IPv6 endpoint, is 250 characters; with an IPv4 endpoint as
well, 256.

| Field | Bytes | Why the new node needs it |
| --- | --- | --- |
| flags | 1 | which endpoints follow: bit 0 IPv6, bit 1 IPv4; no other bit is set |
| inviter's public key | 32 | its peer entry for the inviter |
| IPv6 endpoint | 16 | where to send the handshake, when the inviter has one |
| IPv4 endpoint | 4 | the same, for a new node without IPv6 |
| WireGuard port | 2 | the UDP port at the endpoint |
| HTTPS port | 2 | the TCP port of the join request, at the same address |
| certificate fingerprint | 32 | SHA-256 of the invite's certificate, to pin it |
| inviter's overlay address | 16 | its peer's `allowed_ips`, as a `/128` |
| prefix length | 1 | of the overlay prefix, which the inviter's address is on |
| assigned address | 16 | its own `network.overlay.wireguard.address` |
| mesh identity | 16 | names the mesh; etcd's cluster token |
| etcd state | 1 | 0 none, 1 forms at this join, 2 running |
| etcd port | 2 | only when running: the inviter's member port |
| invite id | 8 | which pending invite this is; derived from the secret |
| secret | 32 | 256 random bits, used once; the HMAC key is derived from it |
| expiry | 4 | seconds since the epoch, UTC |
| checksum | 4 | a truncated or mistyped paste is refused before anything is sent |

`join` refuses, each with its reason and exit 23:

- more than 1024 characters, before anything is decoded (a token is
about 256);
- text that does not start with `keel1:` (the whole `keel mesh join`
line pasted, say), and a `keel2:` or later token, naming the keel it
needs;
- a character outside base64url, and any token whose checksum does not
match: one character changed, or the end cut off;
- a layout that does not match its flags, unknown flags or an unknown
etcd state;
- a value no join could use: a key that is not 32 bytes, an endpoint
that cannot be reached from another machine (loopback, link local,
multicast), a port 0, an overlay prefix outside `fc00::/7`, an
assigned address outside the prefix or equal to the inviter's, an
invite id that is not the one its secret gives;
- an expired token, with the time it expired.

The secret is the only value in the token that is not public, and it is
in no message: no error names the token or its secret, and the `repr`
of a parsed token leaves it out. `invite` writes the token to its
standard output and nowhere else.

## Where the state lives

Under `/var/lib/keel/mesh`, root's, directories mode 0700 and files 0600,
written through a temporary file and a rename as `/var/lib/keel/network`
is, every change under the lock `/var/lib/keel/mesh/lock`. State, not
configuration: never in the spec, never emitted by `inspect`, never in a
backup set.

| Path | What it holds |
| --- | --- |
| `invites/<id>.json` | one pending invite: the reserved address, the expiry, the HTTPS port, the invite's certificate and TLS key, and the HMAC key |
| `identity` | the mesh's identity, 16 bytes in hex |

The invite file holds an HMAC key derived from the secret, never the
secret, so it cannot be turned back into the token. An invite is
consumed once, under the lock: it is marked consumed, and a second
request is told it was already used. A consumed invite keeps its
address reserved until it expires, so no other invite takes the address
before the new node is a peer in the spec; an expired or damaged one is
removed by the next invite.

**Pending:** an invite whose expiry passes while the machine is down is
removed by the next invite, not yet at boot; the boot unit 0048
describes comes with the listener.

The mesh identity is made once and kept: a file that does not hold one
is an error, never replaced, since every other node of the mesh knows
the first. A mesh built by hand before `keel mesh` existed gets it from
its first invite.

## Exit codes

| Code | When |
| --- | --- |
| 0 | the line or the change was printed |
| 2, 3 | the spec cannot be read or is invalid |
| 9 | `join` without `--dry-run`: not implemented yet |
| 15 | `invite` on the live system, not as root |
| 16 | `invite`: `wg` cannot read the key file, or `openssl` cannot make the certificate, or the mesh identity is damaged |
| 23 | `join`: the token is mistyped, truncated, of a later format, inconsistent, or expired |
| 24 | refused: no overlay to invite into, no key yet, no endpoint, no free address; a node in another mesh or at another address of it, or a change the spec would refuse |

## Tests

At four seams, each written down before its tests: the token's
`encode()` and `parse()` (`tests/test_mesh_token.py`, round trips and
every damaged, foreign, inconsistent and expired form, and the secret in
no message and no repr); the allocator's `free_address()`
(`tests/test_mesh_allocate.py`); the store's `reserve`, `find`,
`consume`, `pending` and `expire` against a scratch root
(`tests/test_mesh_invites.py`, modes, one use, expiry, no secret on
disk); and the commands through `keel.cli.main`
(`tests/test_mesh_cli.py`), with the real `wg` (tests/wgtools.py) and
the real `openssl`, so the key and the fingerprint in the token are
checked against what those tools say.
9 changes: 9 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@ network:
| `...peers[].allowed_ips` | system | Required, at least one prefix: what is routed to this peer and accepted from it. For a node, its overlay address as a `/128` (and `/32`). A prefix with host bits set is an error, and so is a prefix given to two peers, since wg would silently keep it for the last one only. Each prefix must be inside the private ranges the overlay addresses use (`fc00::/7`; RFC 1918 or `100.64.0.0/10`): a `/0`, a public prefix (a `::/1` and `8000::/1` pair takes as much as `::/0`) and a prefix that overlaps the uplink or a peer's endpoint are refused (see "Routes" below) |
| `...peers[].persistent_keepalive` | system | Optional, seconds between 1 and 65535: keeps a path through a NAT or a stateful firewall open. Leave it out for none |

**Joining with one command.** The peers and the address need not be
typed: `keel mesh invite`, on a node already in the mesh, prints a `keel
mesh join keel1:<token>` line that carries the inviter's key, endpoint
and port and an address reserved for the new node, and `keel mesh join
--dry-run` on the new node prints the `address` and the peer entry it
makes of them, exactly the fields above (handbook decision 0048,
[docs/mesh.md](mesh.md)). Nothing in the spec records how a peer was
added.

**Routes.** `wg-quick` adds a route for the overlay's own prefixes and
for each peer's `allowed_ips`. A route that covers the uplink would send
the uplink's replies into the overlay, and the machine would be cut off
Expand Down
2 changes: 1 addition & 1 deletion keel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
clients of it.
"""

__version__ = "0.15.4"
__version__ = "0.16.0"
Loading
Loading