diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..66b02eb --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,47 @@ +# The three packages of debian/ (handbook decision 0039): built, source +# and binary, with dpkg-buildpackage and linted with lintian, any error or +# warning failing, on trixie, the release they are for. Then +# tests/package-smoke.sh installs python3-keel-cloud and keel-cloud-api in +# the same container, whose systemd runs, and checks the service: its own +# user, [::]:8443, TLS only. The check is "build / trixie". +# +# keel-overlay-cloud is built and linted here but not installed: it +# depends on keel, which is in the Keel repository and not in Debian. The +# whole path, two nodes and the service, is the real test of README.md. +# +# Nothing is published here: publishing is the maintainer's attended +# release (Keel-Linux/apt). The job runs in a Debian trixie system +# container on the self-hosted runner through lxc-trixie.yml, as every +# Keel package does; no application containers. +name: packages + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + build: + uses: keel-linux/.github/.github/workflows/lxc-trixie.yml@main + with: + timeout: 20 + systemd: true + artifact-dir: dist + artifact-name: keel-cloud-debs + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + ca-certificates curl build-essential debhelper dh-python \ + pybuild-plugin-pyproject python3-all python3-setuptools lintian + work="$(mktemp -d)" + cp -a . "$work/src" + rm -rf "$work/src/.git" + (cd "$work/src" && dpkg-buildpackage -us -uc) + lintian --fail-on error,warning --info --display-info \ + "$work"/keel-cloud_*.changes + mkdir -p dist + cp "$work"/*.deb "$work"/*.dsc "$work"/*.tar.* "$work"/*.changes dist/ + bash tests/package-smoke.sh dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..aa31fec --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +# The tests of the keel_cloud package, with the coverage gate of 95 +# percent (pyproject.toml, fail_under; raise only). The check is +# "coverage / trixie". +# +# Everything comes from Debian 13: the service's aiohttp, pytest and its +# aiohttp and coverage plugins, and openssl for the tests' certificate. +# Nothing is installed from PyPI. A test that cannot run fails rather than +# skips (pyproject.toml, filterwarnings). +# +# The job runs in a Debian trixie system container, an unprivileged LXC +# container on the self-hosted runner, through the reusable workflow +# lxc-trixie.yml of Keel-Linux/.github. Keel runs no application +# containers in CI. A pull request from a fork never reaches the +# self-hosted runner. +name: tests + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + coverage: + uses: keel-linux/.github/.github/workflows/lxc-trixie.yml@main + with: + timeout: 15 + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + ca-certificates openssl python3 python3-aiohttp python3-yaml \ + python3-pytest python3-pytest-aiohttp python3-pytest-cov + python3 -m pytest -q --cov --cov-report=term-missing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aadbf0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.pyc +.coverage +.pytest_cache/ +build/ +*.egg-info/ +.pybuild/ +debian/.debhelper/ +debian/debhelper-build-stamp +debian/files +debian/*.substvars +debian/*.debhelper +debian/*.debhelper.log +debian/tmp/ +debian/python3-keel-cloud/ +debian/keel-cloud-api/ +debian/keel-overlay-cloud/ +dist/ diff --git a/README.md b/README.md index adbedd3..944dc5e 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,266 @@ every feature a node in Keel Cloud has (handbook decisions 0020 and 0046). Any operator can run their own instance from the same packages. The design is handbook decision 0046, "Keel Cloud, version 1 scope". This -repository starts with its Phase A: the API service, and membership and -WireGuard key exchange for the nodes of a set. DNS (Phase B) and the -registry view (Phase C) come later. +repository holds its **Phase A**: the API service, and membership and +WireGuard key exchange for the nodes of a set. DNS with a health check +(Phase B) and the registry view (Phase C) come later and are not here. ## Defaults pending the maintainer's decision 0046 is a proposal. Its recommended answers are the working defaults here, each kept in one place so that changing one later is cheap: -| Open question in 0046 | Working default | -| --- | --- | -| 1. The language | Python, with Debian 13 packages only; nothing is installed from PyPI and nothing goes into the system Python with pip | -| 2. The scope of an API key | two scopes: an account key, which manages the account, and a narrower enrollment key, which nodes hold | -| 3. Operator confirmation of new peers | required by default; automatic admission is a per-set choice | -| 4. The entry secret's lifetime | one reusable entry secret per set, until the operator rotates it | -| 6. How a node learns of changes | outbound long polling over HTTPS; Keel Cloud never connects to a node | -| Transport | IPv6 first, TLS only | +| Open question in 0046 | Working default | Where it lives | +| --- | --- | --- | +| 1. The language | Python, with Debian 13 packages only (`python3-aiohttp`, `python3-yaml`); nothing is installed from PyPI and nothing goes into the system Python with pip | `debian/control` | +| 2. The scope of an API key | two scopes: an **account key**, which manages the account, and a narrower **enrollment key**, which nodes hold and which can only register nodes and read their sets | `keel_cloud/keys.py`, the `principal(...)` scopes in `keel_cloud/api/app.py` | +| 3. Operator confirmation of new peers | **required by default**; automatic admission is a per-set choice (`keel cloud auto-admit on`) | `_admitted` in `keel_cloud/node/pins.py` | +| 4. The entry secret's lifetime | **one reusable entry secret per set**, until the operator rotates it; rotation does not affect peers already pinned | `keel_cloud/proof.py` | +| 6. How a node learns of changes | **outbound long polling** over HTTPS, at most 55 seconds a request, and a retry every 60 seconds when the service is unreachable; Keel Cloud never connects to a node | `MAX_WAIT` in `keel_cloud/api/app.py`, `WAIT` and `RETRY` in `keel_cloud/node/agent.py` | +| Transport | **IPv6 first, TLS only**: the service listens on `[::]:8443` with TLS and has no plain HTTP listener | `conf/api.conf`, `keel_cloud/api/server.py` | + +Questions 5, 7 and 8 of 0046 concern Phase B or are already answered (the +repository exists). + +## The packages + +One Debian source package (0039), three binary packages: + +| Package | Installed on | What it is | +| --- | --- | --- | +| `python3-keel-cloud` | both sides | the Python package `keel_cloud`: the node record, the entry secret proof, the key formats, the service and the agent | +| `keel-cloud-api` | the Keel Cloud machine | `keel-cloud-api`, the service, as the systemd unit `keel-cloud-api.service` running as the system user `keel-cloud`; `keel-cloud`, its command line; `/etc/keel-cloud/api.conf` | +| `keel-overlay-cloud` | a Keel node | `keel-cloud-node`, the node agent, also run as `keel cloud` (keel 0.16.0 or later), and `keel-cloud-node.service`, installed **disabled** | + +## The API, version 1 + +Every request but the health check carries an API key as a bearer token. +Errors are JSON, `{"error": "..."}`. + +| Method and path | Key | What it does | +| --- | --- | --- | +| `GET /v1/health` | none | `{"status": "ok", "version": ...}` | +| `POST /v1/keys` | account | make a key, an enrollment key by default; it is returned once | +| `GET /v1/sets` | account | the account's sets | +| `PATCH /v1/sets/{set}` | account | `{"auto_admit": true, "proof": ...}` with the automatic admission proof, or `{"auto_admit": false}` | +| `POST /v1/sets/{set}/peers` | enrollment or account | register or update a node: `{"record": ..., "proof": ...}`; the set is made on the first registration | +| `GET /v1/sets/{set}/peers` | enrollment or account | the set's nodes, each with its record, its record proof, its status (`pending` or `confirmed`) and its confirmation; the set's automatic admission proof; `?since=REVISION&wait=SECONDS` is the long poll | +| `POST /v1/sets/{set}/peers/confirm` | account | `{"public_key": ..., "confirmation": ...}`: the operator admits a pending node | + +The service checks the shape of the proofs and relays them; it cannot +check or make their value. Accounts and keys are made on the instance +with the command line, which runs as `keel-cloud` even when started as +root: + +``` +keel-cloud account create acme # prints the account key, once +keel-cloud key create acme # prints an enrollment key, once +keel-cloud key revoke acme +keel-cloud peer list acme shop +keel-cloud peer remove acme shop +keel-cloud set auto-admit acme shop off +``` + +Confirming a node and turning automatic admission on need the set's entry +secret, so they are done from a node of the set (`keel cloud confirm`, +`keel cloud auto-admit on`), never from the instance. + +A node record is public information only: the WireGuard public key, the +overlay addresses, the endpoint, the set, and the appliance, role and site +labels (`keel_cloud/record.py`). + +## The trust model + +What the service stores (`keel_cloud/api/store.py`, SQLite): accounts, the +**SHA-256 of each API key** and never the key, sets, and per node its +public record and its proof. It never receives an entry secret, a node's +private key or application data, so it cannot store them. + +**The entry secret proof.** Each set has an entry secret, 32 random bytes +made by the set's first node (`keel cloud entry-secret`) and given by the +operator to every node that joins. A node proves it holds the secret with +an HMAC-SHA256, keyed with it, over its whole record; the service stores +and relays the proof +and cannot make one, for its own key or for a changed endpoint or address. +Every node checks every record of its set against the secret before it +admits it (`keel_cloud/node/pins.py`). + +**Two keys, two scopes.** The enrollment key a node holds can register +and read; it cannot confirm a peer, change a set or make a key, so a key +read off a node does not admit anything. + +**The operator confirms new peers, verifiably.** A new node is held until +the operator runs `keel cloud confirm KEY --account-key-file FILE` on a +node of the set. That node checks the new node's record proof, makes a +**confirmation**, an HMAC with the entry secret over the new node's set, +key and overlay addresses, and sends it with the account key. Every node +checks the confirmation before it admits the peer, so the service cannot +confirm a node by itself, and a confirmation cannot be moved to another +key or other addresses. Automatic admission works the same way: it is +turned on from a node (`keel cloud auto-admit on`) with a proof every node +checks, so the service cannot turn it on either. + +**Peers are pinned per set, on each node.** Once admitted, a peer's key +and overlay addresses are kept in `/var/lib/keel-cloud-node/pins.json` +(root, 0600). Keel Cloud can then bring a newer endpoint for a pinned key, +with a valid proof, and nothing else: it cannot replace the key, change +its addresses, roll its record back, date it ahead, or remove it. A new +key for a known node is a new peer, held like any other. A peer's +addresses must lie inside this node's overlay prefixes, so a peer can +never claim a route to anything else, and a peer the operator declared +by hand in the spec is left as it is. `keel cloud forget KEY` removes a +peer from the node for good: Keel Cloud cannot bring that key back. The +service keeps matching rules on its side: a registered key keeps its +addresses, and two nodes of a set never share one. + +**What a compromised Keel Cloud can and cannot do.** It can only propose +and relay: it can withhold updates, show the operator records that are +not real, or stop answering. Any record, confirmation or automatic +admission it made or changed fails its proof on every node and is not +admitted. It cannot add a peer to a set, cannot confirm one, cannot +change a pinned peer's key or addresses, cannot read traffic between +nodes (WireGuard), and cannot reach into a node: the agent only calls +out, follows no redirect, and reads answers of bounded size. Admitting a +node takes both the entry secret, which only nodes hold, and the account +key, which only the operator holds; a leaked entry secret with a node's +enrollment key can register a node but not admit it. + +**Known limits of Phase A.** Nodes of an account share enrollment keys, +so a holder of one can register nodes in any set of the account (they +stay held) or occupy a key or address that a real node then cannot +register under; per-node keys and quotas are for a later phase. The +service runs SQLite on the event loop, which is fine at Phase A's scale +of a few writes a minute. + +**The node writes only its own spec.** The agent writes the admitted +peers into `network.overlay.wireguard.peers` of this node's +`/etc/keel/instance.yaml`, after `keel spec validate` accepts the new file, +and keeps every peer the operator declared by hand. It never writes +WireGuard's configuration: `keel spec apply --system` converges the spec +and brings the overlay up under the confirmation window of decision 0018, +and `keel network confirm` keeps it. + +## A node in a set + +The spec's `cloud` section (keel 0.16.0, docs/spec.md "cloud" in keel): + +```yaml +cloud: + endpoint: https://cloud.example.org:8443 + api_key: + file: /etc/keel/secrets/cloud_api_key # the enrollment key, 0600 + entry_secret: + file: /etc/keel/secrets/cloud_entry_secret # the set's secret, 0600 + set: shop + ca_file: /etc/keel/cloud-ca.pem # only for a self-signed instance +``` + +The node also declares its own side of the overlay, +`network.overlay.wireguard` with its `address`, as for any Keel overlay. +Then: + +``` +keel cloud entry-secret # first node only: makes and prints the secret +keel cloud enroll # register this node +keel cloud sync # held until the operator confirms +keel cloud confirm KEY --account-key-file FILE # the operator, once per new node +keel cloud sync # the confirmed peers go into the spec +keel spec apply --system # converge the overlay, under its window +keel network confirm # keep it +keel cloud status # the set, and what this node admitted +keel cloud forget KEY # remove a peer here, for good +``` + +The account key file is needed only while confirming; keep it off the +nodes otherwise. + +`systemctl enable --now keel-cloud-node` does the enroll and sync for good, +with long polling; the apply and the confirmation stay the operator's. + +## TLS + +The service has no plain HTTP listener. At installation the package makes +a **self-signed certificate for development** in `/etc/keel-cloud/tls/` +(an ECDSA P-256 key, `root:keel-cloud` 0640), for the machine's name; to +make one that also names the addresses nodes use: + +``` +/usr/libexec/keel-cloud-api/make-dev-certificate cloud.example.org 2001:db8::10 +systemctl restart keel-cloud-api +``` + +Nodes then trust it through `cloud.ca_file`. + +**A certificate from ACME, later.** For an instance on the Internet, +`certificate` and `private_key` in `/etc/keel-cloud/api.conf` point at a +certificate an ACME client keeps current, and a renewal hook restarts +`keel-cloud-api`; nodes then leave `cloud.ca_file` out. 0046 has Keel +Cloud behind Keel Web, with certificates issued by DNS-01 through its own +Keel DNS, so port 80 is never needed; that arrives with Phase B and the +high availability work, and is not built here. + +## Tests + +``` +apt install python3-aiohttp python3-yaml python3-pytest \ + python3-pytest-aiohttp python3-pytest-cov openssl +python3 -m pytest --cov +``` + +The gate is 95 percent of lines and branches (`pyproject.toml`); the +suite runs the agent against the real service over TLS on `[::1]`, with +only the machine (keel and ip) faked. CI runs only through the reusable +workflow `lxc-trixie.yml` of Keel-Linux/.github, in a Debian trixie system +container on the self-hosted runner: + +| Check | Workflow | What it runs | +| --- | --- | --- | +| `coverage / trixie` | `.github/workflows/tests.yml` | the suite and the coverage gate | +| `build / trixie` | `.github/workflows/packages.yml` | `dpkg-buildpackage`, `lintian` failing on any warning, and `tests/package-smoke.sh`: the service installed, running as `keel-cloud` on `[::]:8443`, TLS only, keys stored as hashes | + +## The real test + +On 2026-10-02, on the test VM, three LXC containers made from the Keel +Core image (step 8): `kc-api` with `keel-cloud-api`, and `kc-a` and `kc-b` +with keel 0.16.0 and `keel-overlay-cloud`, each node given only the +enrollment key, the set's entry secret and the instance's certificate. + +- The service ran as `keel-cloud`, listening on `[::]:8443`; plain HTTP + got no answer; the database held no key in plain text. +- Both nodes enrolled and were held as pending. A node's enrollment key + was refused (403) when it tried to confirm. +- A compromised service was simulated by editing its database: it marked + both nodes confirmed with a made-up confirmation and turned automatic + admission on with a made-up proof. Both nodes still held each other. +- The operator confirmed each node from the other with `keel cloud + confirm` and the account key, removed afterwards; each node's spec then + gained the other as its only peer, with its endpoint, its `/128` and a + keepalive, and pinned it. +- `keel spec apply --system` brought `wg0` up on each node under the + window, `keel network confirm` kept it and enabled `wg-quick@wg0`, both + nodes had a handshake, and each pinged the other over the overlay; + `keel diff` showed every overlay field `same`. +- The compromised service then moved one node's endpoint and added a + bogus peer marked confirmed. The other node refused both, since neither + proof verified, left its spec unchanged and kept the overlay up; the + real node's next registration was accepted again. +- `keel-cloud-node.service` ran on a node under its hardening; when the + other node registered a new endpoint, the long poll brought it into the + spec within seconds, on an outbound connection only. + +The containers were destroyed afterwards. + +## Not in Phase A + +- DNS with a health check (Phase B) and the registry view (Phase C). +- An overlay manifest for `cloud` and its installation screen (0046: state + `ask` in every mode), and carrying the entry secret through the first + boot screens; today the operator writes the two secret files. +- The shared secrets of 0041 over the tunnel, and a third node with a + wrong entry secret refused, which are part of 0046's criterion for + Phase A on built images; the unit tests cover the refusal. +- High availability: Keel Cloud on three sites with etcd (0046), instead + of SQLite on one machine. ## License diff --git a/conf/api.conf b/conf/api.conf new file mode 100644 index 0000000..6c503e5 --- /dev/null +++ b/conf/api.conf @@ -0,0 +1,12 @@ +# Keel Cloud API service, read by keel-cloud-api and keel-cloud. +# TLS only: there is no plain HTTP listener. The certificate the package +# made at installation is self-signed, for development; see README.md, +# "TLS", for a certificate from ACME. +[api] +listen = :: +port = 8443 +certificate = /etc/keel-cloud/tls/cert.pem +private_key = /etc/keel-cloud/tls/key.pem +database = /var/lib/keel-cloud/cloud.db +# How often a long poll looks for a change, in seconds. +poll_interval = 1 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..cefca0a --- /dev/null +++ b/debian/changelog @@ -0,0 +1,14 @@ +keel-cloud (0.1.0) unstable; urgency=medium + + * Phase A of handbook decision 0046: the API service, membership and + WireGuard key exchange for the nodes of a set. + * keel-cloud-api: TLS only on [::]:8443, its own system user, SQLite + storing the hashes of API keys only; an account key and a narrower + enrollment key; registration with an entry secret proof the service + relays but cannot make; operator confirmation of new peers; long + polling for changes. + * keel-overlay-cloud: keel-cloud-node, which checks the proofs, pins + admitted peers per set and writes them into the spec's WireGuard + overlay for keel apply to converge. + + -- KeelLinux maintainers Fri, 02 Oct 2026 12:00:00 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..0ae9c2c --- /dev/null +++ b/debian/control @@ -0,0 +1,71 @@ +Source: keel-cloud +Section: net +Priority: optional +Maintainer: KeelLinux maintainers +Build-Depends: + debhelper-compat (= 13), + dh-python, + pybuild-plugin-pyproject, + python3 (>= 3.12), + python3-setuptools, +Standards-Version: 4.7.2 +Rules-Requires-Root: no +X-Python3-Version: >= 3.12 +Homepage: https://github.com/Keel-Linux/keel-cloud +Vcs-Git: https://github.com/Keel-Linux/keel-cloud.git +Vcs-Browser: https://github.com/Keel-Linux/keel-cloud + +Package: python3-keel-cloud +Section: python +Architecture: all +Depends: + python3-yaml, + ${python3:Depends}, + ${misc:Depends}, +Description: Keel Cloud, the library both sides share + Keel Cloud coordinates Keel nodes that their operator owns: it tells the + nodes of a set about each other, so they exchange WireGuard public keys + and endpoints without copy and paste. It is never required: a standalone + Keel node has every feature a node in Keel Cloud has. + . + This package holds the Python package keel_cloud: the node record, the + entry secret proof and the API key formats, the API service and the node + agent. The commands are in keel-cloud-api and keel-overlay-cloud. + +Package: keel-cloud-api +Architecture: all +Depends: + python3-keel-cloud (= ${binary:Version}), + python3-aiohttp, + openssl, + ${python3:Depends}, + ${misc:Depends}, +Description: Keel Cloud, the API service + Keel Cloud coordinates Keel nodes that their operator owns: it tells the + nodes of a set about each other, so they exchange WireGuard public keys + and endpoints without copy and paste. + . + This package is the service: keel-cloud-api, a TLS-only HTTP API on [::] + that runs as its own unprivileged user and keeps its state in SQLite, + storing only the hashes of API keys; and keel-cloud, the command line + that makes accounts and keys on the instance. + +Package: keel-overlay-cloud +Architecture: all +Depends: + python3-keel-cloud (= ${binary:Version}), + keel (>= 0.16.0~), + wireguard-tools, + ${python3:Depends}, + ${misc:Depends}, +Description: Keel Cloud, the node agent + Keel Cloud coordinates Keel nodes that their operator owns: it tells the + nodes of a set about each other, so they exchange WireGuard public keys + and endpoints without copy and paste. + . + This package is the node's side: keel-cloud-node, also reached as + "keel cloud". It reads the cloud section of the instance spec, registers + the node with a proof built from its set's entry secret, checks the + proofs of the other nodes, and writes the peers the operator confirmed + into the spec's WireGuard overlay, which keel spec apply converges. Its + service is installed disabled. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..f725834 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,24 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: keel-cloud +Source: https://github.com/Keel-Linux/keel-cloud + +Files: * +Copyright: 2026 KeelLinux maintainers +License: GPL-3+ + +License: GPL-3+ + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program; If not, see . + . + On Debian systems, the complete text of the GNU General Public License + version 3 can be found in /usr/share/common-licenses/GPL-3. diff --git a/debian/keel-cloud-api.install b/debian/keel-cloud-api.install new file mode 100644 index 0000000..b2ec5e7 --- /dev/null +++ b/debian/keel-cloud-api.install @@ -0,0 +1,4 @@ +usr/bin/keel-cloud +usr/bin/keel-cloud-api +conf/api.conf etc/keel-cloud +scripts/make-dev-certificate usr/libexec/keel-cloud-api diff --git a/debian/keel-cloud-api.keel-cloud-api.service b/debian/keel-cloud-api.keel-cloud-api.service new file mode 100644 index 0000000..db9773f --- /dev/null +++ b/debian/keel-cloud-api.keel-cloud-api.service @@ -0,0 +1,39 @@ +[Unit] +Description=Keel Cloud API service (TLS only) +Documentation=https://github.com/Keel-Linux/keel-cloud +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=keel-cloud +Group=keel-cloud +ExecStart=/usr/bin/keel-cloud-api --config /etc/keel-cloud/api.conf +Restart=on-failure +RestartSec=5 +StateDirectory=keel-cloud +StateDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=yes +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +SystemCallFilter=@system-service +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/debian/keel-cloud-api.manpages b/debian/keel-cloud-api.manpages new file mode 100644 index 0000000..2ee066e --- /dev/null +++ b/debian/keel-cloud-api.manpages @@ -0,0 +1,2 @@ +man/keel-cloud-api.8 +man/keel-cloud.8 diff --git a/debian/keel-cloud-api.postinst b/debian/keel-cloud-api.postinst new file mode 100644 index 0000000..a47c225 --- /dev/null +++ b/debian/keel-cloud-api.postinst @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# The user first, for the key's group; the debhelper snippet below makes +# it too, which is harmless. Then a development certificate, only when +# there is none, so a certificate the operator installed is never +# replaced. +if [ "$1" = "configure" ]; then + systemd-sysusers /usr/lib/sysusers.d/keel-cloud-api.conf + if [ ! -e /etc/keel-cloud/tls/cert.pem ] && \ + [ ! -e /etc/keel-cloud/tls/key.pem ]; then + /usr/libexec/keel-cloud-api/make-dev-certificate + fi +fi + +#DEBHELPER# + +exit 0 diff --git a/debian/keel-cloud-api.postrm b/debian/keel-cloud-api.postrm new file mode 100644 index 0000000..88f3da7 --- /dev/null +++ b/debian/keel-cloud-api.postrm @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +# Purge removes the service's state (accounts, key hashes, node records) +# and the development certificate make-dev-certificate made; a +# certificate the operator installed in its place is left alone. +if [ "$1" = "purge" ]; then + rm -rf /var/lib/keel-cloud + tls=/etc/keel-cloud/tls + if [ -e "$tls/.made-by-make-dev-certificate" ] && (cd "$tls" && + sha256sum --check --status .made-by-make-dev-certificate); then + rm -f "$tls/cert.pem" "$tls/key.pem" \ + "$tls/.made-by-make-dev-certificate" + rmdir "$tls" 2>/dev/null || true + fi +fi + +#DEBHELPER# + +exit 0 diff --git a/debian/keel-cloud-api.sysusers b/debian/keel-cloud-api.sysusers new file mode 100644 index 0000000..33cada9 --- /dev/null +++ b/debian/keel-cloud-api.sysusers @@ -0,0 +1 @@ +u keel-cloud - "Keel Cloud API service" /var/lib/keel-cloud /usr/sbin/nologin diff --git a/debian/keel-overlay-cloud.install b/debian/keel-overlay-cloud.install new file mode 100644 index 0000000..0dfc4b3 --- /dev/null +++ b/debian/keel-overlay-cloud.install @@ -0,0 +1 @@ +usr/bin/keel-cloud-node diff --git a/debian/keel-overlay-cloud.keel-cloud-node.service b/debian/keel-overlay-cloud.keel-cloud-node.service new file mode 100644 index 0000000..0dd5a86 --- /dev/null +++ b/debian/keel-overlay-cloud.keel-cloud-node.service @@ -0,0 +1,41 @@ +[Unit] +Description=Keel Cloud node agent: membership and WireGuard key exchange +Documentation=https://github.com/Keel-Linux/keel-cloud +After=network-online.target +Wants=network-online.target +ConditionPathExists=/etc/keel/instance.yaml + +# Root, because it reads the spec's secret files (root's, 0600) and +# writes the spec; keel network wireguard key may make this node's key +# in /etc/wireguard on the first run. It never writes WireGuard's +# configuration: keel spec apply converges the spec. +[Service] +Type=simple +ExecStart=/usr/bin/keel-cloud-node run +Restart=on-failure +RestartSec=30 +StateDirectory=keel-cloud-node +StateDirectoryMode=0700 +UMask=0077 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/etc/keel -/etc/wireguard +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +SystemCallFilter=@system-service +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/debian/keel-overlay-cloud.manpages b/debian/keel-overlay-cloud.manpages new file mode 100644 index 0000000..f96f8a1 --- /dev/null +++ b/debian/keel-overlay-cloud.manpages @@ -0,0 +1 @@ +man/keel-cloud-node.8 diff --git a/debian/python3-keel-cloud.install b/debian/python3-keel-cloud.install new file mode 100644 index 0000000..993a372 --- /dev/null +++ b/debian/python3-keel-cloud.install @@ -0,0 +1,2 @@ +usr/lib/python3*/dist-packages/keel_cloud +usr/lib/python3*/dist-packages/keel_cloud-*.dist-info diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..f9a7a8f --- /dev/null +++ b/debian/rules @@ -0,0 +1,22 @@ +#! /usr/bin/make -f + +export PYTHONDONTWRITEBYTECODE=1 +# Three binary packages from one Python package: everything is installed +# into debian/tmp and split by the .install files. PYBUILD_NAME is not +# set, since with it pybuild installs into python3-keel-cloud directly. +export PYBUILD_DESTDIR=debian/tmp + +%: + dh $@ --with python3 --buildsystem=pybuild + +# The tests need the service's dependencies and a TLS certificate; CI runs +# them on trixie before it builds the package (.github/workflows). +override_dh_auto_test: + +# The service's user, from debian/keel-cloud-api.sysusers; dh_installsysusers +# is not in compat 13's sequence. Its snippet runs before the unit starts. +override_dh_installsystemd: + dh_installsysusers + dh_installsystemd -pkeel-cloud-api --name=keel-cloud-api + dh_installsystemd -pkeel-overlay-cloud --name=keel-cloud-node \ + --no-enable --no-start diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/keel_cloud/__init__.py b/keel_cloud/__init__.py new file mode 100644 index 0000000..6b5ad1d --- /dev/null +++ b/keel_cloud/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""Keel Cloud: membership and WireGuard key exchange for Keel nodes + +Handbook decision 0046, Phase A. Three parts share this package: + +- keel_cloud.record, keel_cloud.proof and keel_cloud.keys, the formats both + sides agree on; +- keel_cloud.api, the API service and its command line (keel-cloud-api, + keel-cloud), packaged as keel-cloud-api; +- keel_cloud.node, the node agent (keel-cloud-node, also reached as + `keel cloud`), packaged as keel-overlay-cloud. +""" + +__version__ = "0.1.0" diff --git a/keel_cloud/api/__init__.py b/keel_cloud/api/__init__.py new file mode 100644 index 0000000..6a67b89 --- /dev/null +++ b/keel_cloud/api/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The Keel Cloud API service, its storage and its command line""" diff --git a/keel_cloud/api/admin.py b/keel_cloud/api/admin.py new file mode 100644 index 0000000..fcabd94 --- /dev/null +++ b/keel_cloud/api/admin.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""keel-cloud: the command line of a Keel Cloud instance + +Run on the instance itself, by root or the keel-cloud user, against the +service's database. It is how a self-hosted instance makes its accounts +and keys (decision 0046, "The self-hosted path"). A key is printed once, +when it is made, and only its hash is kept. +""" + +import argparse +import json +import os +import pwd +import sqlite3 +import sys + +from keel_cloud.api.config import DEFAULT_PATH, ConfigError, load +from keel_cloud.api.store import Store, StoreError +from keel_cloud.keys import ACCOUNT, ENROLL + +SERVICE_USER = "keel-cloud" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="keel-cloud", description="Accounts, keys, sets and peers of" + " this Keel Cloud instance") + parser.add_argument("--config", default=DEFAULT_PATH, metavar="FILE", + help=f"the service's configuration (default:" + f" {DEFAULT_PATH}), for the database path") + parser.add_argument("--database", metavar="FILE", + help="the database, instead of the configuration's") + parser.add_argument("--json", action="store_true", + help="print lists as JSON") + nouns = parser.add_subparsers(dest="noun", metavar="NOUN", required=True) + + account = nouns.add_parser("account", help="accounts").add_subparsers( + dest="verb", required=True) + create = account.add_parser("create", help="make an account and print" + " its first account key, once") + create.add_argument("account") + + key = nouns.add_parser("key", help="API keys").add_subparsers( + dest="verb", required=True) + create = key.add_parser("create", help="make a key and print it, once") + create.add_argument("account") + create.add_argument("--scope", choices=(ENROLL, ACCOUNT), default=ENROLL) + create.add_argument("--label", default="") + key.add_parser("list", help="the account's keys, never their" + " values").add_argument("account") + revoke = key.add_parser("revoke", help="revoke a key by its id") + revoke.add_argument("account") + revoke.add_argument("id", type=int) + + sets = nouns.add_parser("set", help="sets").add_subparsers( + dest="verb", required=True) + sets.add_parser("list", help="the account's sets").add_argument( + "account") + admit = sets.add_parser( + "auto-admit", help="turn automatic admission off; turning it on, and" + " confirming a node, need the set's entry secret, so they are done" + " on a node of the set: keel cloud auto-admit, keel cloud confirm") + admit.add_argument("account") + admit.add_argument("set") + admit.add_argument("value", choices=("off",)) + + peer = nouns.add_parser("peer", help="the nodes of a set") + peer = peer.add_subparsers(dest="verb", required=True) + listing = peer.add_parser("list", help="the set's nodes and their state") + listing.add_argument("account") + listing.add_argument("set") + remove = peer.add_parser("remove", help="forget a node; nodes that" + " admitted it keep it until their operator" + " removes it there (keel cloud forget)") + remove.add_argument("account") + remove.add_argument("set") + remove.add_argument("public_key") + return parser + + +def run(args, store: Store, out) -> None: + command = (args.noun, args.verb) + if command == ("account", "create"): + print(store.create_account(args.account), file=out) + return + account = store.account_id(args.account) + if command == ("key", "create"): + print(store.create_key(account, args.scope, args.label), file=out) + elif command == ("key", "list"): + show(args, store.list_keys(account), out, + "{id} {scope} {label} created {created} revoked {revoked}") + elif command == ("key", "revoke"): + store.revoke_key(account, args.id) + elif command == ("set", "list"): + show(args, store.list_sets(account), out, + "{name} revision {revision} auto_admit {auto_admit}") + elif command == ("set", "auto-admit"): + store.set_auto_admit(account, args.set, None) + elif command == ("peer", "list"): + show(args, peer_rows(store, account, args.set), out, + "{public_key} {status} {overlay} {endpoint} {appliance}" + " {role} {site}") + else: + store.remove(account, args.set, args.public_key) + + +def peer_rows(store: Store, account: int, name: str) -> list[dict]: + rows = [] + for node in store.view(account, name).nodes: + record = node["record"] + rows.append({"public_key": node["public_key"], + "status": node["status"], + "overlay": ",".join(record["overlay"]), + "endpoint": record["endpoint"], + "appliance": record["appliance"], + "role": record["role"], "site": record["site"]}) + return rows + + +def show(args, rows: list[dict], out, line: str) -> None: + if args.json: + json.dump(rows, out, indent=2) + print(file=out) + return + for row in rows: + print(line.format(**row), file=out) + + +def drop_privileges(user: str = SERVICE_USER, os_module=os) -> None: + """Run as the service's user when started as root + + SQLite makes its journal files as whoever writes, so a root-owned + journal would lock the service out of its own database. + """ + if os_module.geteuid() != 0: + return + try: + entry = pwd.getpwnam(user) + except KeyError: + return + os_module.setgroups([]) + os_module.setgid(entry.pw_gid) + os_module.setuid(entry.pw_uid) + os_module.umask(0o027) + + +def main(argv: list[str] | None = None, out=sys.stdout) -> int: + args = build_parser().parse_args(argv) + drop_privileges() + try: + database = args.database or load(args.config).database + store = Store(database) + except (ConfigError, OSError, sqlite3.Error) as failure: + print(f"keel-cloud: {failure}", file=sys.stderr) + return 1 + try: + run(args, store, out) + except StoreError as refused: + print(f"keel-cloud: {refused}", file=sys.stderr) + return 1 + finally: + store.close() + return 0 diff --git a/keel_cloud/api/app.py b/keel_cloud/api/app.py new file mode 100644 index 0000000..415c86e --- /dev/null +++ b/keel_cloud/api/app.py @@ -0,0 +1,237 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The HTTP API of Keel Cloud, version 1 + +Every request but the health check carries an API key as a bearer +token. The node agent only ever calls out (decision 0024's rule applied +to the control channel); Keel Cloud never connects to a node. + + GET /v1/health no key + POST /v1/keys account key: a new key, enrollment + by default + GET /v1/sets account key: the account's sets + PATCH /v1/sets/{set} account key: automatic admission, + on with its proof, or off + POST /v1/sets/{set}/peers either key: register or update a + node's record, with its proof + GET /v1/sets/{set}/peers either key: the set's nodes; + ?since=REVISION&wait=SECONDS is a + long poll + POST /v1/sets/{set}/peers/confirm account key: admit a pending node, + with a confirmation proof + +The proofs are made on nodes with the set's entry secret; the service +checks their shape and relays them, and every node checks their value. +Errors are JSON, {"error": "..."}, and never say whether a key exists. +""" + +import asyncio +import time + +from aiohttp import web + +from keel_cloud import __version__ +from keel_cloud.api.limits import FailureLimiter +from keel_cloud.api.store import CONFIRMED, Principal, Store, StoreError +from keel_cloud.keys import ACCOUNT, ENROLL +from keel_cloud.proof import proof_error +from keel_cloud.record import label_error, public_key_error, validate_record + +STORE = web.AppKey("store", Store) +LIMITER = web.AppKey("limiter", FailureLimiter) +POLL_INTERVAL = web.AppKey("poll_interval", float) + +MAX_BODY = 16 * 1024 +MAX_WAIT = 55 +MAX_SINCE = 2 ** 62 +CLOCK_SKEW = 300 +STORE_STATUS = {"invalid": 400, "not_found": 404, "conflict": 409, + "full": 409} + + +class ApiError(Exception): + def __init__(self, status: int, message: str, headers=None): + super().__init__(message) + self.status = status + self.headers = headers + + +def principal(request: web.Request, *scopes: str) -> Principal: + """The key's principal, or an ApiError for the handler""" + limiter = request.app[LIMITER] + client = request.remote or "unknown" + if limiter.blocked(client): + raise ApiError(429, "too many failed attempts, wait a minute") + kind, _, key = request.headers.get("Authorization", "").partition(" ") + found = None + if kind == "Bearer" and key: + found = request.app[STORE].authenticate(key.strip()) + if found is None: + limiter.failed(client) + raise ApiError(401, "a valid API key is required", + {"WWW-Authenticate": "Bearer"}) + if found.scope not in scopes: + raise ApiError(403, "this key's scope does not allow it") + return found + + +async def json_body(request: web.Request) -> dict: + try: + value = await request.json() + except ValueError: + raise ApiError(400, "the body is not JSON") + if not isinstance(value, dict): + raise ApiError(400, "the body is a JSON object") + return value + + +def set_name(request: web.Request) -> str: + name = request.match_info["set"] + message = label_error("set", name) + if message: + raise ApiError(400, message) + return name + + +def query_int(request: web.Request, name: str, top: int) -> int: + raw = request.query.get(name, "0") + if not (raw.isascii() and raw.isdigit()) or int(raw) > top: + raise ApiError(400, f"{name}: an integer from 0 to {top}") + return int(raw) + + +async def health(request: web.Request) -> web.Response: + return web.json_response({"status": "ok", "version": __version__}) + + +async def create_key(request: web.Request) -> web.Response: + who = principal(request, ACCOUNT) + data = await json_body(request) + scope = data.get("scope", ENROLL) + key = request.app[STORE].create_key(who.account_id, scope, + data.get("label", "")) + return web.json_response({"key": key, "scope": scope}, status=201) + + +async def list_sets(request: web.Request) -> web.Response: + who = principal(request, ACCOUNT) + return web.json_response( + {"sets": request.app[STORE].list_sets(who.account_id)}) + + +async def patch_set(request: web.Request) -> web.Response: + who = principal(request, ACCOUNT) + name = set_name(request) + data = await json_body(request) + if data == {"auto_admit": False}: + proof = None + elif set(data) == {"auto_admit", "proof"} and data["auto_admit"] is True \ + and not proof_error(data["proof"]): + proof = data["proof"] + else: + raise ApiError(400, 'the body is {"auto_admit": false}, or' + ' {"auto_admit": true, "proof": ...} made on a node of' + " the set") + revision = request.app[STORE].set_auto_admit(who.account_id, name, proof) + return web.json_response({"set": name, "auto_admit": proof is not None, + "revision": revision}) + + +async def register(request: web.Request) -> web.Response: + """A node registers or updates its record; the proof is relayed""" + who = principal(request, ENROLL, ACCOUNT) + name = set_name(request) + data = await json_body(request) + if set(data) != {"record", "proof"}: + raise ApiError(400, 'the body is {"record": ..., "proof": ...}') + record = data["record"] + problems = validate_record(record) + problems += [p for p in [proof_error(data["proof"])] if p] + if problems: + raise ApiError(400, "; ".join(problems)) + if record["set"] != name: + raise ApiError(400, "set: the record names another set") + if abs(record["ts"] - request.app[STORE].now()) > CLOCK_SKEW: + raise ApiError(400, f"ts: more than {CLOCK_SKEW} seconds from the" + " service's clock") + result = request.app[STORE].register(who.account_id, record, + data["proof"]) + status = 201 if result["created"] else 200 + return web.json_response({"set": name, "status": result["status"], + "revision": result["revision"]}, status=status) + + +def listing(view) -> dict: + """The set as the service holds it; each node decides what it admits""" + nodes = [{"record": node["record"], "proof": node["proof"], + "status": node["status"], + "confirmation": node["confirmation"]} + for node in view.nodes] + return {"set": view.name, "revision": view.revision, + "auto_admit": view.auto_admit is not None, + "auto_admit_proof": view.auto_admit, "nodes": nodes} + + +async def peers(request: web.Request) -> web.Response: + """The set's nodes; a long poll when since and wait are given""" + who = principal(request, ENROLL, ACCOUNT) + name = set_name(request) + since = query_int(request, "since", MAX_SINCE) + wait = query_int(request, "wait", MAX_WAIT) + store = request.app[STORE] + deadline = time.monotonic() + wait + while (store.revision(who.account_id, name) <= since + and time.monotonic() < deadline): + await asyncio.sleep(request.app[POLL_INTERVAL]) + return web.json_response(listing(store.view(who.account_id, name))) + + +async def confirm(request: web.Request) -> web.Response: + """The operator admits a node: an account key, never a node's key, + and a confirmation made on a node of the set, which nodes check""" + who = principal(request, ACCOUNT) + name = set_name(request) + data = await json_body(request) + key = data.get("public_key") + if set(data) != {"public_key", "confirmation"} or public_key_error(key) \ + or proof_error(data["confirmation"]): + raise ApiError(400, 'the body is {"public_key": KEY, "confirmation":' + " PROOF}, the proof made on a node of the set") + revision = request.app[STORE].confirm(who.account_id, name, key, + data["confirmation"]) + return web.json_response({"set": name, "public_key": key, + "status": CONFIRMED, "revision": revision}) + + +@web.middleware +async def json_errors(request: web.Request, handler): + try: + return await handler(request) + except ApiError as refused: + return web.json_response({"error": str(refused)}, + status=refused.status, + headers=refused.headers) + except StoreError as refused: + return web.json_response({"error": str(refused)}, + status=STORE_STATUS[refused.kind]) + except web.HTTPException as raised: + if raised.status < 400: + raise + return web.json_response({"error": raised.reason}, + status=raised.status) + + +def make_app(store: Store, poll_interval: float = 1.0, + limiter: FailureLimiter | None = None) -> web.Application: + app = web.Application(client_max_size=MAX_BODY, + middlewares=[json_errors]) + app[STORE] = store + app[LIMITER] = limiter or FailureLimiter() + app[POLL_INTERVAL] = float(poll_interval) + app.router.add_get("/v1/health", health) + app.router.add_post("/v1/keys", create_key) + app.router.add_get("/v1/sets", list_sets) + app.router.add_patch("/v1/sets/{set}", patch_set) + app.router.add_post("/v1/sets/{set}/peers", register) + app.router.add_get("/v1/sets/{set}/peers", peers) + app.router.add_post("/v1/sets/{set}/peers/confirm", confirm) + return app diff --git a/keel_cloud/api/config.py b/keel_cloud/api/config.py new file mode 100644 index 0000000..4a4044b --- /dev/null +++ b/keel_cloud/api/config.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The service's configuration file, /etc/keel-cloud/api.conf + + [api] + listen = :: + port = 8443 + certificate = /etc/keel-cloud/tls/cert.pem + private_key = /etc/keel-cloud/tls/key.pem + database = /var/lib/keel-cloud/cloud.db + +TLS only, IPv6 first: there is no plain HTTP listener, and `::` on Linux +also accepts IPv4 unless the system disables it. +""" + +import configparser +import ipaddress +from dataclasses import dataclass + +DEFAULT_PATH = "/etc/keel-cloud/api.conf" +DEFAULTS = { + "listen": "::", + "port": "8443", + "certificate": "/etc/keel-cloud/tls/cert.pem", + "private_key": "/etc/keel-cloud/tls/key.pem", + "database": "/var/lib/keel-cloud/cloud.db", + "poll_interval": "1", +} + + +class ConfigError(Exception): + pass + + +@dataclass(frozen=True) +class Config: + listen: str + port: int + certificate: str + private_key: str + database: str + poll_interval: float + + +def load(path: str) -> Config: + parser = configparser.ConfigParser() + try: + with open(path, encoding="utf-8") as stream: + parser.read_file(stream) + except FileNotFoundError: + pass + except (OSError, configparser.Error) as failure: + raise ConfigError(f"{path}: {failure}") from failure + section = dict(DEFAULTS) + if parser.has_section("api"): + unknown = set(parser["api"]) - set(DEFAULTS) + if unknown: + raise ConfigError(f"{path}: [api]: unknown key" + f" {', '.join(sorted(unknown))}") + section.update(parser["api"]) + return _checked(path, section) + + +def _checked(path: str, section: dict) -> Config: + try: + ipaddress.ip_address(section["listen"]) + except ValueError: + raise ConfigError(f"{path}: listen: an IP address, :: for all") + port = section["port"] + if not port.isdigit() or not 1 <= int(port) <= 65535: + raise ConfigError(f"{path}: port: 1 to 65535") + try: + interval = float(section["poll_interval"]) + except ValueError: + interval = 0.0 + if not 0.05 <= interval <= 10: + raise ConfigError(f"{path}: poll_interval: 0.05 to 10 seconds") + for key in ("certificate", "private_key", "database"): + if not section[key].startswith("/"): + raise ConfigError(f"{path}: {key}: an absolute path") + return Config(section["listen"], int(port), section["certificate"], + section["private_key"], section["database"], interval) diff --git a/keel_cloud/api/limits.py b/keel_cloud/api/limits.py new file mode 100644 index 0000000..8ed93b5 --- /dev/null +++ b/keel_cloud/api/limits.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""A limit on failed authentications per client address + +A key has 256 bits of entropy, so the limit is not what keeps a key from +being guessed; it keeps a client that keeps sending bad keys from costing +the service a database lookup each time. After `limit` failures within +`window` seconds a client is refused with 429 until the window passes. +""" + +import time +from collections import deque + + +class FailureLimiter: + def __init__(self, limit: int = 20, window: float = 60.0, + clock=time.monotonic, max_clients: int = 10000): + self.limit = limit + self.window = window + self.clock = clock + self.max_clients = max_clients + self.failures: dict[str, deque] = {} + + def _recent(self, client: str) -> deque: + times = self.failures.get(client) + if times is None: + return deque() + cutoff = self.clock() - self.window + while times and times[0] < cutoff: + times.popleft() + if not times: + del self.failures[client] + return times + + def blocked(self, client: str) -> bool: + return len(self._recent(client)) >= self.limit + + def failed(self, client: str) -> None: + if client not in self.failures and \ + len(self.failures) >= self.max_clients: + for stale in list(self.failures): + self._recent(stale) + if len(self.failures) >= self.max_clients: + self.failures.pop(next(iter(self.failures))) + self.failures.setdefault(client, deque()).append(self.clock()) diff --git a/keel_cloud/api/server.py b/keel_cloud/api/server.py new file mode 100644 index 0000000..8b16151 --- /dev/null +++ b/keel_cloud/api/server.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""keel-cloud-api: the service, TLS only, on [::] by default""" + +import argparse +import ssl +import sys + +from aiohttp import web + +from keel_cloud.api.app import make_app +from keel_cloud.api.config import DEFAULT_PATH, ConfigError, load +from keel_cloud.api.store import Store + + +def tls_context(certificate: str, private_key: str) -> ssl.SSLContext: + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(certificate, private_key) + return context + + +def main(argv: list[str] | None = None, run=web.run_app) -> int: + parser = argparse.ArgumentParser( + prog="keel-cloud-api", + description="The Keel Cloud API service (TLS only)") + parser.add_argument("--config", default=DEFAULT_PATH, metavar="FILE", + help=f"configuration file (default: {DEFAULT_PATH})") + args = parser.parse_args(argv) + try: + config = load(args.config) + context = tls_context(config.certificate, config.private_key) + except (ConfigError, OSError, ssl.SSLError) as failure: + print(f"keel-cloud-api: {failure}", file=sys.stderr) + return 1 + store = Store(config.database) + try: + run(make_app(store, config.poll_interval), host=config.listen, + port=config.port, ssl_context=context, print=None) + finally: + store.close() + return 0 diff --git a/keel_cloud/api/store.py b/keel_cloud/api/store.py new file mode 100644 index 0000000..859aff9 --- /dev/null +++ b/keel_cloud/api/store.py @@ -0,0 +1,351 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""Keel Cloud's state in SQLite + +What is stored: accounts, the SHA-256 of each API key (never the key), +sets, per node its public record, the record proof that came with it and +the operator's confirmation, and per set the automatic admission proof +when the operator turned it on. The proofs are made on nodes with the +set's entry secret and only relayed here (keel_cloud.proof). What is +never stored, because it never reaches the service: an entry secret, a +node's private key, and any application data. + +Every change to a set raises its revision, which is what a long poll +waits on. The revision lives in the database, so a change made with the +keel-cloud command line wakes a waiting node as one made over the API +does. + +Peers are pinned here as on the nodes: a public key is a node, its +overlay addresses do not change once registered (a new address is a new +key, so a new peer), and two nodes of a set never share an address. +""" + +import ipaddress +import json +import sqlite3 +import time +from dataclasses import dataclass + +from keel_cloud.keys import ACCOUNT, ENROLL, key_hash, key_scope, new_key +from keel_cloud.record import label_error + +PENDING = "pending" +CONFIRMED = "confirmed" +MAX_SETS_PER_ACCOUNT = 64 +MAX_NODES_PER_SET = 256 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id), + scope TEXT NOT NULL CHECK (scope IN ('account', 'enroll')), + hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created INTEGER NOT NULL, + revoked INTEGER +); +CREATE TABLE IF NOT EXISTS sets ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id), + name TEXT NOT NULL, + auto_admit TEXT, + revision INTEGER NOT NULL DEFAULT 0, + created INTEGER NOT NULL, + UNIQUE (account_id, name) +); +CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY, + set_id INTEGER NOT NULL REFERENCES sets(id), + public_key TEXT NOT NULL, + record TEXT NOT NULL, + proof TEXT NOT NULL, + ts INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed')), + confirmation TEXT, + created INTEGER NOT NULL, + updated INTEGER NOT NULL, + UNIQUE (set_id, public_key) +); +""" + + +class StoreError(Exception): + """A request the state refuses; `kind` maps to an HTTP status""" + + def __init__(self, kind: str, message: str): + super().__init__(message) + self.kind = kind + + +@dataclass(frozen=True) +class Principal: + account_id: int + scope: str + key_id: int + + +@dataclass(frozen=True) +class SetView: + name: str + revision: int + auto_admit: str | None # the proof, None when admission is manual + nodes: tuple + + +class Store: + def __init__(self, path: str, clock=time.time): + self.clock = clock + self.db = sqlite3.connect(path, isolation_level=None, + check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.execute("PRAGMA foreign_keys = ON") + self.db.execute("PRAGMA busy_timeout = 5000") + if path != ":memory:": + self.db.execute("PRAGMA journal_mode = WAL") + self.db.executescript(SCHEMA) + + def close(self) -> None: + self.db.close() + + def now(self) -> int: + return int(self.clock()) + + def _transaction(self): + return _Transaction(self.db) + + # --- accounts and keys ------------------------------------------------ + + def create_account(self, name: str) -> str: + """Make an account and return its first account key, shown once""" + error = label_error("account", name) + if error: + raise StoreError("invalid", error) + with self._transaction(): + try: + cursor = self.db.execute( + "INSERT INTO accounts (name, created) VALUES (?, ?)", + (name, self.now())) + except sqlite3.IntegrityError: + raise StoreError("conflict", f"account {name}: exists") + return self._insert_key(cursor.lastrowid, ACCOUNT, "first") + + def account_id(self, name: str) -> int: + row = self.db.execute("SELECT id FROM accounts WHERE name = ?", + (name,)).fetchone() + if row is None: + raise StoreError("not_found", f"account {name}: no such account") + return row["id"] + + def create_key(self, account_id: int, scope: str, label: str = "") -> str: + if scope not in (ACCOUNT, ENROLL): + raise StoreError("invalid", "scope: account or enroll") + if not isinstance(label, str) or len(label) > 64: + raise StoreError("invalid", "label: at most 64 characters") + return self._insert_key(account_id, scope, label) + + def _insert_key(self, account_id: int, scope: str, label: str) -> str: + key = new_key(scope) + self.db.execute( + "INSERT INTO api_keys (account_id, scope, hash, label, created)" + " VALUES (?, ?, ?, ?, ?)", + (account_id, scope, key_hash(key), label, self.now())) + return key + + def list_keys(self, account_id: int) -> list[dict]: + rows = self.db.execute( + "SELECT id, scope, label, created, revoked FROM api_keys" + " WHERE account_id = ? ORDER BY id", (account_id,)) + return [dict(row) for row in rows] + + def revoke_key(self, account_id: int, key_id: int) -> None: + cursor = self.db.execute( + "UPDATE api_keys SET revoked = ? WHERE id = ? AND account_id = ?" + " AND revoked IS NULL", (self.now(), key_id, account_id)) + if cursor.rowcount != 1: + raise StoreError("not_found", f"key {key_id}: no such live key") + + def authenticate(self, key: str) -> Principal | None: + # The prefix is part of what is hashed, so a key whose prefix was + # changed to another scope matches no row. + if key_scope(key) is None: + return None + row = self.db.execute( + "SELECT id, account_id, scope FROM api_keys WHERE hash = ?" + " AND revoked IS NULL", (key_hash(key),)).fetchone() + if row is None: + return None + return Principal(row["account_id"], row["scope"], row["id"]) + + # --- sets --------------------------------------------------------------- + + def _set_row(self, account_id: int, name: str): + return self.db.execute( + "SELECT * FROM sets WHERE account_id = ? AND name = ?", + (account_id, name)).fetchone() + + def _require_set(self, account_id: int, name: str): + row = self._set_row(account_id, name) + if row is None: + raise StoreError("not_found", f"set {name}: no such set") + return row + + def _ensure_set(self, account_id: int, name: str): + row = self._set_row(account_id, name) + if row is not None: + return row + count = self.db.execute( + "SELECT COUNT(*) FROM sets WHERE account_id = ?", + (account_id,)).fetchone()[0] + if count >= MAX_SETS_PER_ACCOUNT: + raise StoreError("full", f"at most {MAX_SETS_PER_ACCOUNT} sets" + " per account") + self.db.execute( + "INSERT INTO sets (account_id, name, created) VALUES (?, ?, ?)", + (account_id, name, self.now())) + return self._set_row(account_id, name) + + def _bump(self, set_id: int) -> int: + self.db.execute("UPDATE sets SET revision = revision + 1" + " WHERE id = ?", (set_id,)) + return self.db.execute("SELECT revision FROM sets WHERE id = ?", + (set_id,)).fetchone()[0] + + def list_sets(self, account_id: int) -> list[dict]: + rows = self.db.execute( + "SELECT name, auto_admit, revision FROM sets WHERE account_id = ?" + " ORDER BY name", (account_id,)) + return [{"name": r["name"], "auto_admit": r["auto_admit"] is not None, + "revision": r["revision"]} for r in rows] + + def set_auto_admit(self, account_id: int, name: str, + proof: str | None) -> int: + """Automatic admission on, with the operator's proof, or off (None) + + Only a node can make the proof, and every node checks it, so the + service cannot turn automatic admission on by itself. + """ + with self._transaction(): + row = self._require_set(account_id, name) + self.db.execute("UPDATE sets SET auto_admit = ? WHERE id = ?", + (proof, row["id"])) + return self._bump(row["id"]) + + def revision(self, account_id: int, name: str) -> int: + return self._require_set(account_id, name)["revision"] + + # --- nodes -------------------------------------------------------------- + + def register(self, account_id: int, record: dict, proof: str) -> dict: + """Add or update the record of one node; the record is valid""" + with self._transaction(): + row = self._ensure_set(account_id, record["set"]) + node = self.db.execute( + "SELECT * FROM nodes WHERE set_id = ? AND public_key = ?", + (row["id"], record["public_key"])).fetchone() + if node is None: + return self._insert_node(row, record, proof) + return self._update_node(row, node, record, proof) + + def _insert_node(self, row, record: dict, proof: str) -> dict: + nodes = self._nodes(row["id"]) + if len(nodes) >= MAX_NODES_PER_SET: + raise StoreError("full", f"at most {MAX_NODES_PER_SET} nodes" + " per set") + taken = {address for node in nodes + for address in _addresses(node["record"]["overlay"])} + clash = taken & _addresses(record["overlay"]) + if clash: + raise StoreError( + "conflict", f"overlay: {', '.join(sorted(map(str, clash)))}" + " belongs to another node of the set") + now = self.now() + self.db.execute( + "INSERT INTO nodes (set_id, public_key, record, proof, ts," + " status, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (row["id"], record["public_key"], json.dumps(record), proof, + record["ts"], PENDING, now, now)) + return {"status": PENDING, "created": True, + "revision": self._bump(row["id"])} + + def _update_node(self, row, node, record: dict, proof: str) -> dict: + stored = json.loads(node["record"]) + if record["ts"] <= node["ts"]: + raise StoreError("conflict", "ts: not newer than the record" + " already registered") + if record["overlay"] != stored["overlay"]: + raise StoreError( + "conflict", "overlay: a registered key keeps its addresses;" + " a node with new addresses registers a new key") + self.db.execute( + "UPDATE nodes SET record = ?, proof = ?, ts = ?, updated = ?" + " WHERE id = ?", + (json.dumps(record), proof, record["ts"], self.now(), node["id"])) + return {"status": node["status"], "created": False, + "revision": self._bump(row["id"])} + + def _nodes(self, set_id: int) -> list[dict]: + rows = self.db.execute( + "SELECT public_key, record, proof, status, confirmation, created," + " updated FROM nodes WHERE set_id = ? ORDER BY id", (set_id,)) + return [{"public_key": r["public_key"], + "record": json.loads(r["record"]), "proof": r["proof"], + "status": r["status"], "confirmation": r["confirmation"], + "created": r["created"], "updated": r["updated"]} + for r in rows] + + def view(self, account_id: int, name: str) -> SetView: + row = self._require_set(account_id, name) + return SetView(name, row["revision"], row["auto_admit"], + tuple(self._nodes(row["id"]))) + + def confirm(self, account_id: int, name: str, public_key: str, + confirmation: str) -> int: + """The operator admits a pending node, with a confirmation proof + + The proof is made on a node of the set, where the entry secret is, + and every node checks it; the service only relays it. + """ + with self._transaction(): + row = self._require_set(account_id, name) + cursor = self.db.execute( + "UPDATE nodes SET status = ?, confirmation = ?, updated = ?" + " WHERE set_id = ? AND public_key = ?", + (CONFIRMED, confirmation, self.now(), row["id"], public_key)) + if cursor.rowcount != 1: + raise StoreError("not_found", "public_key: no such node in" + f" set {name}") + return self._bump(row["id"]) + + def remove(self, account_id: int, name: str, public_key: str) -> int: + with self._transaction(): + row = self._require_set(account_id, name) + cursor = self.db.execute( + "DELETE FROM nodes WHERE set_id = ? AND public_key = ?", + (row["id"], public_key)) + if cursor.rowcount != 1: + raise StoreError("not_found", "public_key: no such node in" + f" set {name}") + return self._bump(row["id"]) + + +def _addresses(overlay: list[str]) -> set: + return {ipaddress.ip_address(item) for item in overlay} + + +class _Transaction: + """BEGIN IMMEDIATE ... COMMIT, ROLLBACK on any exception""" + + def __init__(self, db: sqlite3.Connection): + self.db = db + + def __enter__(self): + self.db.execute("BEGIN IMMEDIATE") + return self + + def __exit__(self, kind, value, traceback): + self.db.execute("ROLLBACK" if kind else "COMMIT") + return False diff --git a/keel_cloud/keys.py b/keel_cloud/keys.py new file mode 100644 index 0000000..f10fba1 --- /dev/null +++ b/keel_cloud/keys.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""API keys: two scopes, shown once, stored as hashes + +An account key manages its account: it creates enrollment keys, confirms +new peers and changes a set's admission. An enrollment key is the one a +node holds: it registers and updates node records and reads the peers of +its sets, nothing else (decision 0046, open question 2). A key read off a +node therefore cannot confirm a peer. + +A key is a fixed prefix, which says its format and scope so that a key +pasted into the wrong field is recognised, and 32 random bytes. The +service stores its SHA-256 only: a key has 256 bits of entropy, so a +plain hash cannot be reversed by guessing, and it can be looked up. +""" + +import hashlib +import re +import secrets + +ACCOUNT = "account" +ENROLL = "enroll" +PREFIXES = {ACCOUNT: "kc1a_", ENROLL: "kc1e_"} +KEY_BYTES = 32 +KEY_RE = re.compile(r"^kc1[ae]_[A-Za-z0-9_-]{43}$") + + +def new_key(scope: str) -> str: + if scope not in PREFIXES: + raise ValueError(f"scope: one of {', '.join(PREFIXES)}") + return PREFIXES[scope] + secrets.token_urlsafe(KEY_BYTES) + + +def key_scope(key: str) -> str | None: + """The scope a key's prefix names, or None for anything else""" + if not isinstance(key, str) or not KEY_RE.fullmatch(key): + return None + for scope, prefix in PREFIXES.items(): + if key.startswith(prefix): + return scope + return None # pragma: no cover - KEY_RE admits only the two prefixes + + +def key_hash(key: str) -> str: + return hashlib.sha256(key.encode("ascii")).hexdigest() diff --git a/keel_cloud/node/__init__.py b/keel_cloud/node/__init__.py new file mode 100644 index 0000000..cb782d4 --- /dev/null +++ b/keel_cloud/node/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The node agent of Keel Cloud: keel-cloud-node, or `keel cloud` + +It reads the spec's `cloud` section, registers this node, and writes the +peers its set admits into this node's own +`network.overlay.wireguard.peers`. It never writes WireGuard's +configuration: `keel spec apply --system` converges the spec under the +confirmation window of handbook decision 0018. +""" + + +class NodeError(Exception): + """Something the operator has to fix; the message says what""" diff --git a/keel_cloud/node/agent.py b/keel_cloud/node/agent.py new file mode 100644 index 0000000..dcd85fc --- /dev/null +++ b/keel_cloud/node/agent.py @@ -0,0 +1,345 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""What the node agent does: enroll, sync, run, confirm, auto-admit, +forget, entry-secret, status + +Everything that touches the machine goes through `System` (commands, the +clock, sleeping, output), so the agent is tested without a machine. +""" + +import ipaddress +import json +import os +import subprocess +import sys +import time +from dataclasses import dataclass, field +from typing import Callable, TextIO + +from keel_cloud.node import NodeError, pins, spec +from keel_cloud.node.client import Client, CloudError +from keel_cloud.proof import ( + make_auto_admit, + make_confirmation, + make_proof, + new_entry_secret, + parse_entry_secret, + verify_proof, +) +from keel_cloud.record import ( + ULA, + label_error, + public_key_error, + validate_record, +) + +STATE_DIR_DEFAULT = "/var/lib/keel-cloud-node" +PINS_FILE = "pins.json" +REFRESH = 600 +WAIT = 50 +RETRY = 60 +SKIPPED_FLAGS = ("temporary", "deprecated", "tentative", "dadfailed") +APPLY_HINT = ("the spec's peers changed: run keel spec apply --system, then" + " keel network confirm, as for any overlay change (decision" + " 0018)") + + +def run_command(argv: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(argv, capture_output=True, text=True, check=False) + + +@dataclass +class System: + run: Callable = run_command + clock: Callable = time.time + sleep: Callable = time.sleep + out: TextIO = field(default_factory=lambda: sys.stdout) + + def say(self, text: str) -> None: + print(text, file=self.out, flush=True) + + +def public_key(system: System, spec_path: str) -> str: + """This node's key, as keel prints it; keel makes the pair if needed""" + result = system.run(["keel", "network", "wireguard", "key", "--spec", + spec_path]) + lines = [line.strip() for line in result.stdout.splitlines() + if line.strip()] + if result.returncode != 0 or not lines or public_key_error(lines[-1]): + detail = result.stderr.strip() or "no public key printed" + raise NodeError(f"keel network wireguard key: {detail}") + return lines[-1] + + +def _rank(address) -> int: + """IPv6 first, and a global address before a unique local one""" + if address.version == 6: + return 1 if address in ULA else 0 + return 2 if address.is_global else 3 + + +def endpoint_address(system: System, overlay: spec.Overlay) -> str | None: + """Where peers reach this node: its best address and the overlay port""" + result = system.run(["ip", "-j", "address", "show", "scope", "global"]) + try: + links = json.loads(result.stdout) if result.returncode == 0 else [] + except ValueError: + links = [] + candidates = [] + for link in links if isinstance(links, list) else []: + if not isinstance(link, dict) or \ + link.get("ifname") == overlay.interface: + continue + for info in link.get("addr_info") or []: + if not isinstance(info, dict) or "local" not in info or any( + info.get(flag) for flag in SKIPPED_FLAGS): + continue + try: + address = ipaddress.ip_address(info["local"]) + except ValueError: + continue + candidates.append((_rank(address), len(candidates), address)) + if not candidates: + return None + best = min(candidates)[2] + host = f"[{best}]" if best.version == 6 else str(best) + return f"{host}:{overlay.listen_port}" + + +def checked(listing) -> dict: + """A listing this node can read, or a CloudError: a service that + answers anything else stops this round, not the agent""" + revision = listing.get("revision") if isinstance(listing, dict) else None + if isinstance(revision, bool) or not isinstance(revision, int) or \ + revision < 0 or not isinstance(listing.get("nodes"), list): + raise CloudError(None, "the service answered a listing this node" + " cannot read") + return listing + + +def _label(value) -> str | None: + return value if value is not None and not label_error("", value) \ + else None + + +def build_record(settings: spec.CloudSettings, doc: dict, + overlay: spec.Overlay, key: str, endpoint: str | None, + now: float) -> dict: + appliance = (doc.get("appliance") or {}).get("name") + server = (doc.get("database") or {}).get("server") or {} + return {"set": settings.set, "public_key": key, + "overlay": list(overlay.addresses), "endpoint": endpoint, + "appliance": _label(appliance), "role": _label(server.get("role")), + "site": None, "ts": int(now)} + + +@dataclass +class Context: + doc: dict + settings: spec.CloudSettings + overlay: spec.Overlay + secret: bytes + client: Client + + +class Agent: + def __init__(self, spec_path: str, state_dir: str = STATE_DIR_DEFAULT, + system: System | None = None, client_factory=Client, + secret_owner: int = 0, wait: int = WAIT): + self.spec_path = spec_path + self.state_dir = state_dir + self.system = system or System() + self.client_factory = client_factory + self.owner = secret_owner + self.wait = wait + + def context(self) -> Context: + doc = spec.load(self.spec_path) + settings = spec.cloud_settings(doc) + overlay = spec.overlay(doc) + try: + secret = parse_entry_secret( + spec.read_secret(settings.entry_secret_file, self.owner)) + except ValueError as failure: + raise NodeError(f"{settings.entry_secret_file}: {failure}") + key = spec.read_secret(settings.api_key_file, self.owner) + client = self.client_factory(settings.endpoint, key, + settings.ca_file) + return Context(doc, settings, overlay, secret, client) + + def record(self, ctx: Context, endpoint: str | None = None) -> dict: + key = public_key(self.system, self.spec_path) + endpoint = endpoint or endpoint_address(self.system, ctx.overlay) + record = build_record(ctx.settings, ctx.doc, ctx.overlay, key, + endpoint, self.system.clock()) + errors = validate_record(record) + if errors: + raise NodeError("this node's record: " + "; ".join(errors)) + return record + + def enroll(self, endpoint: str | None = None) -> dict: + """Register this node, or bring its record up to date""" + ctx = self.context() + record = self.record(ctx, endpoint) + result = ctx.client.register(ctx.settings.set, record, + make_proof(ctx.secret, record)) + self.system.say(f"registered {record['public_key']} in set" + f" {ctx.settings.set} at {record['endpoint']}:" + f" {result['status']}") + return record + + def validate(self, path: str) -> str: + result = self.system.run(["keel", "spec", "validate", "--spec", + path]) + if result.returncode == 0: + return "" + return (result.stderr + result.stdout).strip() or "refused" + + def sync(self, since: int = 0, wait: int = 0) -> int: + """Admit what the set proposes, and write the pinned peers""" + ctx = self.context() + listing = checked(ctx.client.peers(ctx.settings.set, since, wait)) + # Read again after the long poll, so an edit the operator made + # meanwhile is kept + doc = spec.load(self.spec_path) + overlay = spec.overlay(doc) + own = pins.Own(public_key(self.system, self.spec_path), + tuple(overlay.addresses), overlay.networks) + path = self.pins_path() + state = pins.load(path, ctx.settings.set) + rules = pins.Rules( + ctx.secret, ctx.settings.set, int(self.system.clock()), + pins.auto_admit(listing, ctx.secret, ctx.settings.set), + frozenset(spec.peer_keys(doc) - set(state.pins)), + frozenset(state.forgotten)) + decision = pins.decide(state.pins, listing["nodes"], own, rules) + for note in decision.notes: + self.system.say(note) + if decision.pins != state.pins: + pins.save(path, ctx.settings.set, + pins.State(decision.pins, state.forgotten)) + updated = spec.with_peers(doc, pins.wireguard_peers(decision.pins)) + if spec.peers_of(updated) != spec.peers_of(doc): + spec.write(self.spec_path, updated, self.validate) + self.system.say(APPLY_HINT) + return listing["revision"] + + def pins_path(self) -> str: + os.makedirs(self.state_dir, mode=0o700, exist_ok=True) + return os.path.join(self.state_dir, PINS_FILE) + + def run(self, rounds: int | None = None) -> None: + """Enroll, then long poll for changes, outbound only, for ever""" + revision, enrolled_at, sent = 0, None, None + while rounds is None or rounds > 0: + rounds = None if rounds is None else rounds - 1 + try: + now = self.system.clock() + ctx = self.context() + current = {k: v for k, v in self.record(ctx).items() + if k != "ts"} + if enrolled_at is None or current != sent or \ + now - enrolled_at >= REFRESH: + self.enroll(current["endpoint"]) + enrolled_at, sent = now, current + revision = self.sync(revision, self.wait) + except (CloudError, NodeError, OSError, ValueError, TypeError, + KeyError) as failure: + self.system.say(f"keel-cloud-node: {failure}; again in" + f" {RETRY}s") + self.system.sleep(RETRY) + + def operator(self, account_key_file: str) -> tuple[Context, Client]: + """This node's context, and a client with the operator's key""" + ctx = self.context() + key = spec.read_secret(account_key_file, self.owner) + return ctx, self.client_factory(ctx.settings.endpoint, key, + ctx.settings.ca_file) + + def confirm(self, public_key_value: str, account_key_file: str) -> None: + """The operator admits a node: a confirmation made here, where the + entry secret is, sent with the account key""" + message = public_key_error(public_key_value) + if message: + raise NodeError(message) + ctx, client = self.operator(account_key_file) + listing = checked(client.peers(ctx.settings.set)) + found = [n for n in listing["nodes"] if isinstance(n, dict) and + isinstance(n.get("record"), dict) and + n["record"].get("public_key") == public_key_value] + if not found: + raise NodeError(f"{public_key_value}: no such node in set" + f" {ctx.settings.set}") + record = found[0]["record"] + if validate_record(record) or not verify_proof( + ctx.secret, record, found[0].get("proof")): + raise NodeError(f"{public_key_value}: its record proof does not" + " verify with this set's entry secret; not" + " confirmed") + client.confirm(ctx.settings.set, public_key_value, + make_confirmation(ctx.secret, record)) + self.system.say(f"confirmed {public_key_value}" + f" ({', '.join(record['overlay'])}) in set" + f" {ctx.settings.set}") + + def auto_admit(self, on: bool, account_key_file: str) -> None: + """The operator lets the set admit on the record proof alone""" + ctx, client = self.operator(account_key_file) + proof = make_auto_admit(ctx.secret, ctx.settings.set) if on else None + client.auto_admit(ctx.settings.set, proof) + self.system.say(f"automatic admission {'on' if on else 'off'} in" + f" set {ctx.settings.set}") + + def forget(self, key: str) -> None: + """The operator removes a peer here, for good: Keel Cloud cannot + bring it back, a new key is needed""" + message = public_key_error(key) + if message: + raise NodeError(message) + doc = spec.load(self.spec_path) + settings = spec.cloud_settings(doc) + path = self.pins_path() + state = pins.load(path, settings.set) + kept = {k: v for k, v in state.pins.items() if k != key} + pins.save(path, settings.set, + pins.State(kept, tuple(state.forgotten) + (key,))) + if key in spec.peer_keys(doc): + spec.write(self.spec_path, spec.without_peer(doc, key), + self.validate) + self.system.say(APPLY_HINT) + self.system.say(f"forgot {key} in set {settings.set}") + + def entry_secret(self) -> None: + """Print the set's entry secret, making it on the first node""" + settings = spec.cloud_settings(spec.load(self.spec_path)) + path = settings.entry_secret_file + if not os.path.exists(path): + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600) + with os.fdopen(descriptor, "w", encoding="ascii") as stream: + stream.write(new_entry_secret() + "\n") + self.system.say(f"made a new entry secret in {path}") + value = spec.read_secret(path, self.owner) + try: + parse_entry_secret(value) + except ValueError as failure: + raise NodeError(f"{path}: {failure}") + self.system.say(value) + + def status(self) -> None: + ctx = self.context() + listing = checked(ctx.client.peers(ctx.settings.set)) + own = public_key(self.system, self.spec_path) + pinned = pins.load(self.pins_path(), ctx.settings.set).pins + self.system.say(f"set {ctx.settings.set} at" + f" {ctx.settings.endpoint}, revision" + f" {listing['revision']}") + for node in listing["nodes"]: + record = (node.get("record") if isinstance(node, dict) else + None) or {} + key = record.get("public_key") + state = ("this node" if key == own else "pinned" + if key in pinned else + f"{node.get('status')} in Keel Cloud, not admitted here") + self.system.say(f"{key} {','.join(record.get('overlay', []))}" + f" {record.get('endpoint')} {state}") diff --git a/keel_cloud/node/cli.py b/keel_cloud/node/cli.py new file mode 100644 index 0000000..790f471 --- /dev/null +++ b/keel_cloud/node/cli.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""keel-cloud-node, also reached as `keel cloud` + + keel-cloud-node entry-secret print the set's entry secret, made + here on the set's first node + keel-cloud-node enroll register this node in its set + keel-cloud-node sync [--wait N] admit what the set proposes and + write the pinned peers into the spec + keel-cloud-node confirm KEY --account-key-file FILE + the operator admits a pending node + keel-cloud-node auto-admit on|off --account-key-file FILE + admission on the record proof alone + keel-cloud-node forget KEY remove a peer here for good + keel-cloud-node status the set as Keel Cloud and this node + see it + keel-cloud-node run enroll and long poll for ever, the + keel-cloud-node service +""" + +import argparse +import os +import sys + +from keel_cloud.node import NodeError +from keel_cloud.node.agent import STATE_DIR_DEFAULT, WAIT, Agent +from keel_cloud.node.client import CloudError +from keel_cloud.node.spec import SPEC_DEFAULT, SPEC_ENV + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="keel-cloud-node", + description="This node's side of Keel Cloud: membership and" + " WireGuard key exchange for the overlay of its set") + parser.add_argument("--spec", default=os.environ.get(SPEC_ENV, + SPEC_DEFAULT), + metavar="FILE", help=f"the instance spec (default:" + f" ${SPEC_ENV} or {SPEC_DEFAULT})") + parser.add_argument("--state-dir", default=STATE_DIR_DEFAULT, + metavar="DIR", help="where the pinned peers are kept" + f" (default: {STATE_DIR_DEFAULT})") + actions = parser.add_subparsers(dest="action", metavar="ACTION", + required=True) + actions.add_parser("entry-secret", help="print the set's entry secret," + " making it when the spec's file does not exist") + enroll = actions.add_parser("enroll", help="register this node") + enroll.add_argument("--endpoint", metavar="HOST:PORT", + help="the endpoint peers reach this node at" + " (default: its best address, IPv6 first)") + sync = actions.add_parser("sync", help="admit and write peers") + sync.add_argument("--wait", type=int, default=0, metavar="SECONDS", + help=f"long poll for a change first, at most {WAIT}") + sync.add_argument("--since", type=int, default=0, metavar="REVISION", + help="the revision already seen, for --wait") + confirm = actions.add_parser("confirm", help="admit a pending node: a" + " confirmation made here with the entry" + " secret, sent with the account key") + confirm.add_argument("public_key") + admit = actions.add_parser("auto-admit", help="let the set admit new" + " nodes on their record proof alone, or stop") + admit.add_argument("value", choices=("on", "off")) + for action in (confirm, admit): + action.add_argument("--account-key-file", required=True, + metavar="FILE", help="a file holding the account" + " key, root's and mode 0600") + forget = actions.add_parser("forget", help="remove a peer from this node" + " for good; Keel Cloud cannot bring it back") + forget.add_argument("public_key") + actions.add_parser("status", help="the set and its peers") + actions.add_parser("run", help="enroll, then long poll for changes") + return parser + + +def main(argv: list[str] | None = None, agent_factory=Agent) -> int: + args = build_parser().parse_args(argv) + agent = agent_factory(args.spec, args.state_dir) + try: + if args.action == "entry-secret": + agent.entry_secret() + elif args.action == "enroll": + agent.enroll(args.endpoint) + elif args.action == "sync": + agent.sync(args.since, max(0, min(args.wait, WAIT))) + elif args.action == "confirm": + agent.confirm(args.public_key, args.account_key_file) + elif args.action == "auto-admit": + agent.auto_admit(args.value == "on", args.account_key_file) + elif args.action == "forget": + agent.forget(args.public_key) + elif args.action == "status": + agent.status() + else: + agent.run() + except (NodeError, CloudError) as failure: + print(f"keel-cloud-node: {failure}", file=sys.stderr) + return 1 + return 0 diff --git a/keel_cloud/node/client.py b/keel_cloud/node/client.py new file mode 100644 index 0000000..7ae34d8 --- /dev/null +++ b/keel_cloud/node/client.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The agent's HTTPS client: outbound only, TLS verified, the standard +library alone, so a node needs no package beyond Python and PyYAML""" + +import json +import ssl +import urllib.error +import urllib.request + +from keel_cloud import __version__ + +TIMEOUT = 30 +LONG_POLL_MARGIN = 15 +# 256 nodes of about 1 KiB each, with room to spare +MAX_ANSWER = 1024 * 1024 + + +class CloudError(Exception): + def __init__(self, status: int | None, message: str): + super().__init__(message) + self.status = status + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + """The API never redirects; following one would carry the key away""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise CloudError(code, f"the service redirected to {newurl};" + " refused, the key is not sent elsewhere") + + +class Client: + def __init__(self, endpoint: str, key: str, ca_file: str | None = None, + opener=None): + self.endpoint = endpoint.rstrip("/") + self.key = key + if opener is None: + context = ssl.create_default_context(cafile=ca_file) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context), NoRedirect()) + self.opener = opener + + def call(self, method: str, path: str, body: dict | None = None, + timeout: float = TIMEOUT) -> dict: + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request( + self.endpoint + path, data=data, method=method, + headers={"Authorization": f"Bearer {self.key}", + "Content-Type": "application/json", + "User-Agent": f"keel-cloud-node/{__version__}"}) + try: + with self.opener.open(request, timeout=timeout) as response: + body = response.read(MAX_ANSWER + 1) + if len(body) > MAX_ANSWER: + raise CloudError(None, f"{self.endpoint}: an answer of" + f" more than {MAX_ANSWER} bytes") + return json.loads(body or b"{}") + except urllib.error.HTTPError as failure: + raise CloudError(failure.code, _message(failure)) from failure + except (urllib.error.URLError, OSError, ValueError) as failure: + reason = getattr(failure, "reason", failure) + raise CloudError(None, f"{self.endpoint}: {reason}") from failure + + def register(self, set_name: str, record: dict, proof: str) -> dict: + return self.call("POST", f"/v1/sets/{set_name}/peers", + {"record": record, "proof": proof}) + + def peers(self, set_name: str, since: int = 0, wait: int = 0) -> dict: + return self.call("GET", f"/v1/sets/{set_name}/peers?since={since}" + f"&wait={wait}", timeout=wait + LONG_POLL_MARGIN) + + def confirm(self, set_name: str, public_key: str, + confirmation: str) -> dict: + return self.call("POST", f"/v1/sets/{set_name}/peers/confirm", + {"public_key": public_key, + "confirmation": confirmation}) + + def auto_admit(self, set_name: str, proof: str | None) -> dict: + body = {"auto_admit": False} if proof is None else \ + {"auto_admit": True, "proof": proof} + return self.call("PATCH", f"/v1/sets/{set_name}", body) + + +def _message(failure: urllib.error.HTTPError) -> str: + try: + return json.loads(failure.read())["error"] + except (ValueError, KeyError, TypeError, OSError): + return f"HTTP {failure.code}" diff --git a/keel_cloud/node/pins.py b/keel_cloud/node/pins.py new file mode 100644 index 0000000..d1269f1 --- /dev/null +++ b/keel_cloud/node/pins.py @@ -0,0 +1,222 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""Which peers this node admits: the trust model of decision 0046 + +Keel Cloud only proposes. A node of the set becomes a peer of this node +when all of these hold, checked here, on the node, with the set's entry +secret, which Keel Cloud never holds: + +- its record is valid, names this set, and is not dated in the future; +- its record proof verifies, so Keel Cloud did not make or change it; +- the operator admitted it: its confirmation verifies, or the set's + automatic admission proof does (keel_cloud.proof). Both are made on a + node of the set, so Keel Cloud cannot confirm a node by itself; +- its overlay addresses are inside this node's overlay prefixes and clash + with no address this node or an admitted peer already has; +- its key is not one the operator declared by hand in the spec, nor one + the operator forgot here (`keel cloud forget`). + +Admitted peers are **pinned** in this node's state, per set: their key +and their addresses. Afterwards Keel Cloud can bring a newer endpoint +for a pinned key, with a proof, but can never replace the key, change +its addresses, roll its record back to an older one, or remove it: a +peer that disappears from Keel Cloud stays until the operator removes it +here. A new key for a known node is a new peer, held like any other. +""" + +import ipaddress +import json +import os +from dataclasses import dataclass, field + +from keel_cloud.node import NodeError +from keel_cloud.proof import verify_auto_admit, verify_confirmation, \ + verify_proof +from keel_cloud.record import host_prefixes, validate_record + +STATE_VERSION = 1 +KEEPALIVE = 25 +CLOCK_SKEW = 300 + + +@dataclass(frozen=True) +class Own: + """This node: its key, its overlay addresses and their prefixes""" + key: str + addresses: tuple + networks: tuple + + +@dataclass(frozen=True) +class Rules: + secret: bytes + set_name: str + now: int + auto_admit: bool = False + declared: frozenset = frozenset() + forgotten: frozenset = frozenset() + + +@dataclass(frozen=True) +class Decision: + pins: dict + notes: list = field(default_factory=list) + held: list = field(default_factory=list) + + +@dataclass(frozen=True) +class State: + pins: dict + forgotten: tuple = () + + +def _read(path: str) -> dict: + try: + with open(path, encoding="utf-8") as stream: + state = json.load(stream) + except FileNotFoundError: + return {"version": STATE_VERSION, "sets": {}} + except (OSError, ValueError) as failure: + raise NodeError(f"{path}: unreadable pin state: {failure}") + if not isinstance(state, dict) or state.get("version") != STATE_VERSION \ + or not isinstance(state.get("sets"), dict): + raise NodeError(f"{path}: not a pin state of version" + f" {STATE_VERSION}") + return state + + +def load(path: str, set_name: str) -> State: + """The pins of one set; empty when there is no state yet""" + entry = _read(path)["sets"].get(set_name, {}) + return State(dict(entry.get("pins", {})), + tuple(entry.get("forgotten", ()))) + + +def save(path: str, set_name: str, state: State) -> None: + """Write the whole state, 0600, flushed to disk before it replaces""" + sets = dict(_read(path)["sets"]) + sets[set_name] = {"pins": state.pins, + "forgotten": sorted(set(state.forgotten))} + temporary = f"{path}.new" + if os.path.lexists(temporary): + os.unlink(temporary) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | + os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump({"version": STATE_VERSION, "sets": sets}, stream, + indent=2, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + + +def _pin(record: dict) -> dict: + return {key: record[key] for key in + ("overlay", "endpoint", "ts", "appliance", "role", "site")} + + +def _short(key: str) -> str: + return key[:10] + "..." + + +def _addresses(items) -> set: + return {ipaddress.ip_address(a) for a in items} + + +def _clash(pins: dict, own: Own, record: dict) -> str | None: + """Why the record's addresses cannot be this node's peer, or None""" + wanted = _addresses(record["overlay"]) + outside = [a for a in wanted if not any( + a.version == n.version and a in n for n in own.networks)] + if outside: + return (f"{', '.join(map(str, outside))} is outside this node's" + " overlay prefixes") + taken = _addresses(own.addresses) + for pin in pins.values(): + taken |= _addresses(pin["overlay"]) + if wanted & taken: + return (f"{', '.join(map(str, wanted & taken))} is already this" + " node's or a pinned peer's") + return None + + +def _admitted(node: dict, record: dict, rules: Rules) -> bool: + return rules.auto_admit or verify_confirmation( + rules.secret, record, node.get("confirmation")) + + +def judge(pins: dict, node: dict, own: Own, + rules: Rules) -> tuple[dict | None, str | None, bool]: + """One node of the listing: (new pin or None, note, held)""" + record = node.get("record") + errors = validate_record(record) + if errors: + return None, f"a record was refused: {'; '.join(errors)}", False + key = record["public_key"] + if record["set"] != rules.set_name: + return None, f"{_short(key)}: names set {record['set']}", False + if not verify_proof(rules.secret, record, node.get("proof")): + return None, (f"{_short(key)}: its proof does not verify with the" + " entry secret; not admitted"), False + if record["ts"] > rules.now + CLOCK_SKEW: + return None, f"{_short(key)}: its record is dated ahead", False + pinned = pins.get(key) + if pinned is not None: + if record["overlay"] != pinned["overlay"]: + return None, (f"{_short(key)}: a pinned peer keeps its" + " addresses; the change is refused"), False + if record["ts"] <= pinned["ts"]: + return None, None, False + return _pin(record), None, False + if key in rules.forgotten or key in rules.declared: + return None, None, False + if not _admitted(node, record, rules): + return None, (f"{_short(key)} {', '.join(record['overlay'])}: held," + " waiting for the operator's confirmation"), True + reason = _clash(pins, own, record) + if reason: + return None, f"{_short(key)}: {reason}", False + return _pin(record), f"{_short(key)}: admitted and pinned", False + + +def decide(pins: dict, nodes: list, own: Own, rules: Rules) -> Decision: + """The new pins from a listing of the set; `pins` is not changed""" + result = dict(pins) + notes, held = [], [] + for node in nodes: + if not isinstance(node, dict): + notes.append("a node of the listing is not a mapping") + continue + record = node.get("record") + if isinstance(record, dict) and record.get("public_key") == own.key: + continue + try: + pin, note, waiting = judge(result, node, own, rules) + except (ValueError, TypeError, KeyError) as failure: + notes.append(f"a record was refused: {failure}") + continue + if pin is not None: + result[record["public_key"]] = pin + if note: + notes.append(note) + if waiting: + held.append(record["public_key"]) + return Decision(result, notes, held) + + +def auto_admit(listing: dict, secret: bytes, set_name: str) -> bool: + """Whether the operator turned automatic admission on, provably""" + return bool(listing.get("auto_admit")) and verify_auto_admit( + secret, set_name, listing.get("auto_admit_proof")) + + +def wireguard_peers(pins: dict) -> dict: + """The spec's peers for the pinned keys, by key""" + peers = {} + for key, pin in pins.items(): + peer = {"public_key": key} + if pin["endpoint"]: + peer["endpoint"] = pin["endpoint"] + peer["allowed_ips"] = host_prefixes(pin["overlay"]) + peer["persistent_keepalive"] = KEEPALIVE + peers[key] = peer + return peers diff --git a/keel_cloud/node/spec.py b/keel_cloud/node/spec.py new file mode 100644 index 0000000..922970a --- /dev/null +++ b/keel_cloud/node/spec.py @@ -0,0 +1,227 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""What the agent reads from this node's spec, and the one thing it writes + +It reads the `cloud` section (decision 0046) and this node's side of the +overlay; it writes `network.overlay.wireguard.peers` and nothing else. +Peers the operator declared by hand, whose keys Keel Cloud did not +admit, are kept as they are. + + cloud: + endpoint: https://cloud.example.org:8443 + api_key: + file: /etc/keel/secrets/cloud_api_key + entry_secret: + file: /etc/keel/secrets/cloud_entry_secret + set: shop + ca_file: /etc/keel/cloud-ca.pem # optional + +Secrets are referenced by file, root's and mode 0600, never inlined. +""" + +import copy +import ipaddress +import os +import stat +from dataclasses import dataclass +from urllib.parse import urlsplit + +import yaml + +from keel_cloud.node import NodeError +from keel_cloud.record import label_error + +SPEC_DEFAULT = "/etc/keel/instance.yaml" +SPEC_ENV = "KEEL_SPEC" +CLOUD_KEYS = ("endpoint", "api_key", "entry_secret", "set", "ca_file") +DEFAULT_PORT = 51820 + + +@dataclass(frozen=True) +class CloudSettings: + endpoint: str + api_key_file: str + entry_secret_file: str + set: str + ca_file: str | None + + +@dataclass(frozen=True) +class Overlay: + interface: str + addresses: list + listen_port: int + networks: tuple = () + + +def load(path: str) -> dict: + try: + with open(path, encoding="utf-8") as stream: + doc = yaml.safe_load(stream) + except OSError as failure: + raise NodeError(f"{path}: {failure.strerror}") from failure + except yaml.YAMLError as failure: + raise NodeError(f"{path}: not YAML: {failure}") from failure + if not isinstance(doc, dict): + raise NodeError(f"{path}: not a Keel spec") + return doc + + +def _file_reference(cloud: dict, key: str) -> str: + value = cloud.get(key) + if value == "skip" and key == "api_key": + raise NodeError("cloud.api_key is skip: this node is standalone") + if not isinstance(value, dict) or set(value) != {"file"} or \ + not str(value["file"]).startswith("/"): + raise NodeError(f"cloud.{key}: a secret reference, file: and an" + " absolute path") + return value["file"] + + +def cloud_settings(doc: dict) -> CloudSettings: + cloud = doc.get("cloud") + if not isinstance(cloud, dict): + raise NodeError("the spec has no cloud section: this node is" + " standalone") + unknown = sorted(set(cloud) - set(CLOUD_KEYS)) + if unknown: + raise NodeError(f"cloud.{unknown[0]}: unknown key") + endpoint = cloud.get("endpoint") + parts = urlsplit(endpoint) if isinstance(endpoint, str) else None + if parts is None or parts.scheme != "https" or not parts.hostname or \ + parts.path not in ("", "/") or parts.query or parts.fragment: + raise NodeError("cloud.endpoint: an https:// URL with no path; TLS" + " only") + error = label_error("cloud.set", cloud.get("set")) + if error: + raise NodeError(error) + ca_file = cloud.get("ca_file") + if ca_file is not None and not str(ca_file).startswith("/"): + raise NodeError("cloud.ca_file: an absolute path") + return CloudSettings(endpoint.rstrip("/"), + _file_reference(cloud, "api_key"), + _file_reference(cloud, "entry_secret"), + cloud["set"], ca_file) + + +def read_secret(path: str, owner: int = 0) -> str: + """A secret file's content; it must be `owner`'s and mode 0600 + + Opened first and checked on the open descriptor, never through a + symbolic link, so what is checked is what is read. + """ + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | + os.O_NONBLOCK) + except OSError as failure: + raise NodeError(f"{path}: {failure.strerror}") from failure + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise NodeError(f"{path}: not a regular file") + if info.st_uid != owner or info.st_mode & 0o077: + raise NodeError(f"{path}: must be root's and mode 0600") + value = os.read(descriptor, 4096).decode("ascii").strip() + except UnicodeDecodeError as failure: + raise NodeError(f"{path}: not ASCII text") from failure + finally: + os.close(descriptor) + if not value: + raise NodeError(f"{path}: empty") + return value + + +def overlay(doc: dict) -> Overlay: + """This node's side of network.overlay.wireguard""" + network = doc.get("network") or {} + wireguard = ((network.get("overlay") or {}).get("wireguard") + if isinstance(network, dict) else None) + if not isinstance(wireguard, dict) or "address" not in wireguard: + raise NodeError("network.overlay.wireguard.address: Keel Cloud" + " exchanges the overlay's keys, so the spec declares" + " the overlay first") + addresses, networks = [], [] + for key in ("address", "ipv4_address"): + if wireguard.get(key) is not None: + try: + interface = ipaddress.ip_interface(str(wireguard[key])) + except ValueError as failure: + raise NodeError(f"network.overlay.wireguard.{key}:" + f" {failure}") from failure + addresses.append(str(interface.ip)) + networks.append(interface.network) + return Overlay(str(wireguard.get("interface", "wg0")), addresses, + int(wireguard.get("listen_port", DEFAULT_PORT)), + tuple(networks)) + + +def peers_of(doc: dict) -> list: + wireguard = doc["network"]["overlay"]["wireguard"] + return list(wireguard.get("peers") or []) + + +def peer_keys(doc: dict) -> set: + return {peer.get("public_key") for peer in peers_of(doc) + if isinstance(peer, dict)} + + +def without_peer(doc: dict, key: str) -> dict: + """A copy of the spec without the peer of `key`""" + result = copy.deepcopy(doc) + result["network"]["overlay"]["wireguard"]["peers"] = [ + peer for peer in peers_of(result) + if not (isinstance(peer, dict) and peer.get("public_key") == key)] + return result + + +def with_peers(doc: dict, managed: dict) -> dict: + """A copy of the spec whose peers include `managed`, keyed by key + + A managed key already in the list is replaced where it stands, so the + order the operator sees does not move; new ones are appended. + """ + result = copy.deepcopy(doc) + remaining = dict(managed) + peers = [] + for peer in peers_of(result): + key = peer.get("public_key") if isinstance(peer, dict) else None + peers.append(remaining.pop(key) if key in remaining else peer) + peers.extend(remaining[key] for key in sorted(remaining)) + result["network"]["overlay"]["wireguard"]["peers"] = peers + return result + + +def dump(doc: dict) -> str: + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False) + + +def write(path: str, doc: dict, validate) -> None: + """Write the spec whole, after `validate` accepts the new file + + The new spec goes to a file beside the old one, with its mode, owner + and group, is handed to `validate` (keel spec validate), and only then + replaces it, so a spec keel would refuse never takes the old one's + place. + """ + directory = os.path.dirname(os.path.abspath(path)) + temporary = os.path.join(directory, f".{os.path.basename(path)}.cloud") + info = os.stat(path) + mode = stat.S_IMODE(info.st_mode) + if os.path.lexists(temporary): + os.unlink(temporary) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | + os.O_NOFOLLOW, mode) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(dump(doc)) + stream.flush() + os.fsync(stream.fileno()) + os.chown(temporary, info.st_uid, info.st_gid) + os.chmod(temporary, mode) + errors = validate(temporary) + if errors: + raise NodeError("keel refuses the spec with the new peers, so it" + f" was not written: {errors}") + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/keel_cloud/proof.py b/keel_cloud/proof.py new file mode 100644 index 0000000..22502f9 --- /dev/null +++ b/keel_cloud/proof.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The entry secret and the proofs made with it (decision 0046) + +Each set has an entry secret, made by its first node and shown to the +operator there, who gives it to every node that joins. Three proofs are +made with it, each an HMAC-SHA256 keyed with the secret over its own +domain, so one can never stand for another: + +- **the record proof**: a node proves it holds the secret over its whole + record (its public key, set, overlay addresses, endpoint, labels and a + timestamp); +- **the confirmation**: the operator admits a node, on a node of the set + where the secret is, over the node's set, key and overlay addresses; +- **the automatic admission**: the operator lets the set admit new nodes + on their record proof alone. + +Keel Cloud stores and relays the proofs and never sees the secret, so it +cannot make any of them: it can neither invent a node nor confirm one, +nor turn automatic admission on. Every node checks all three. The secret +is 32 random bytes, so it cannot be guessed from the proofs. +""" + +import hashlib +import hmac +import json +import re +import secrets + +from keel_cloud.record import FIELDS + +RECORD_DOMAIN = b"keel-cloud record v1\n" +CONFIRM_DOMAIN = b"keel-cloud confirmation v1\n" +AUTO_ADMIT_DOMAIN = b"keel-cloud automatic admission v1\n" +SECRET_BYTES = 32 +# What secrets.token_urlsafe(32) prints, so a secret is never weaker than +# one keel-cloud-node would make +MIN_SECRET_LENGTH = 43 +SECRET_RE = re.compile(r"[A-Za-z0-9_-]+") +PROOF_RE = re.compile(r"[0-9a-f]{64}") + + +def new_entry_secret() -> str: + """A fresh secret, printable so the operator can carry it to a node""" + return secrets.token_urlsafe(SECRET_BYTES) + + +def parse_entry_secret(text: str) -> bytes: + """The secret as the HMAC key; ValueError when it is not one""" + value = text.strip() + if len(value) < MIN_SECRET_LENGTH or not SECRET_RE.fullmatch(value): + raise ValueError( + f"an entry secret is at least {MIN_SECRET_LENGTH} characters of" + " A-Z, a-z, 0-9, '-' and '_', as keel-cloud-node entry-secret" + " makes it" + ) + return value.encode("ascii") + + +def _json(body: dict) -> bytes: + return json.dumps(body, sort_keys=True, separators=(",", ":"), + ensure_ascii=True).encode("ascii") + + +def _mac(secret: bytes, message: bytes) -> str: + return hmac.new(secret, message, hashlib.sha256).hexdigest() + + +def _same(expected: str, proof: object) -> bool: + if not isinstance(proof, str) or not PROOF_RE.fullmatch(proof): + return False + return hmac.compare_digest(expected, proof) + + +def canonical(record: dict) -> bytes: + """The bytes the record proof covers: the record's fields, in one order""" + return RECORD_DOMAIN + _json({key: record[key] for key in FIELDS}) + + +def make_proof(secret: bytes, record: dict) -> str: + return _mac(secret, canonical(record)) + + +def verify_proof(secret: bytes, record: dict, proof: object) -> bool: + return _same(make_proof(secret, record), proof) + + +def make_confirmation(secret: bytes, record: dict) -> str: + """The operator's admission of one node: its set, key and addresses""" + body = {key: record[key] for key in ("set", "public_key", "overlay")} + return _mac(secret, CONFIRM_DOMAIN + _json(body)) + + +def verify_confirmation(secret: bytes, record: dict, proof: object) -> bool: + return _same(make_confirmation(secret, record), proof) + + +def make_auto_admit(secret: bytes, set_name: str) -> str: + return _mac(secret, AUTO_ADMIT_DOMAIN + _json({"set": set_name})) + + +def verify_auto_admit(secret: bytes, set_name: str, proof: object) -> bool: + return _same(make_auto_admit(secret, set_name), proof) + + +def proof_error(proof: object, name: str = "proof") -> str | None: + """The shape the service can check; only a node can check the value""" + if not isinstance(proof, str) or not PROOF_RE.fullmatch(proof): + return f"{name}: 64 lower case hexadecimal digits, an HMAC-SHA256" + return None diff --git a/keel_cloud/record.py b/keel_cloud/record.py new file mode 100644 index 0000000..cb99c8a --- /dev/null +++ b/keel_cloud/record.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The node record: what a node tells Keel Cloud about itself + +A record holds public information only (decision 0046, "The API key"): +the node's WireGuard public key, its overlay addresses, the endpoint its +peers reach it at, and its set, site, appliance and role. Both the +service and the node agent validate it with this module, so neither +accepts what the other would refuse. + +The record is what the entry secret proof covers (keel_cloud.proof), so +every field a peer acts on is in it: a peer's key, the addresses routed +to it and its endpoint. +""" + +import ipaddress +import re +from typing import Any + +FIELDS = ("set", "public_key", "overlay", "endpoint", "appliance", "role", + "site", "ts") +# One label of a domain name, lower case: what a set, a site, an +# appliance and a role are named with, so they can become DNS labels in +# Phase B without a second rule. +LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +# What `wg pubkey` prints: 32 bytes in base64, so 43 characters, the last +# of which carries only 4 bits, and one "=". +PUBLIC_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$") +HOST_RE = re.compile( + r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*" + r"[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$" +) +OPTIONAL_LABELS = ("appliance", "role", "site") +PRIVATE_V4 = tuple(ipaddress.ip_network(n) for n in + ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", + "100.64.0.0/10")) +ULA = ipaddress.ip_network("fc00::/7") +MAX_TS = 2 ** 40 + + +def label_error(name: str, value: Any) -> str | None: + if not isinstance(value, str) or not LABEL_RE.fullmatch(value): + return (f"{name}: must be a lower case DNS label (a-z, 0-9 and '-'," + " at most 63)") + return None + + +def public_key_error(value: Any) -> str | None: + if not isinstance(value, str) or not PUBLIC_KEY_RE.fullmatch(value): + return "public_key: not a WireGuard public key as wg pubkey prints it" + return None + + +def overlay_address(value: Any) -> ipaddress.IPv4Address | \ + ipaddress.IPv6Address: + """One overlay address, private, as an ip_address; ValueError if not""" + if not isinstance(value, str): + raise ValueError("not a string") + if "%" in value: + raise ValueError("no scope: an overlay address is not link local") + address = ipaddress.ip_address(value) + if address.version == 6 and address not in ULA: + raise ValueError("an IPv6 overlay address is a unique local" + " address, fc00::/7") + if address.version == 4 and not any(address in n for n in PRIVATE_V4): + raise ValueError("an IPv4 overlay address is private, RFC 1918 or" + " 100.64.0.0/10") + return address + + +def overlay_errors(value: Any) -> list[str]: + """The overlay: one IPv6 address, then at most one IPv4 address""" + if not isinstance(value, list) or not 1 <= len(value) <= 2: + return ["overlay: a list of one IPv6 address and at most one IPv4" + " address"] + errors = [] + families = [] + for item in value: + try: + address = overlay_address(item) + except ValueError as error: + errors.append(f"overlay: {item!r}: {error}") + continue + if str(address) != item: + errors.append(f"overlay: {item!r}: write it as {address}") + families.append(address.version) + if not errors and (families[0] != 6 or len(set(families)) != len( + families)): + errors.append("overlay: the IPv6 address first, and one per family") + return errors + + +def endpoint_error(value: Any) -> str | None: + """`[v6]:port`, `v4:port` or `name:port`, as wg writes an endpoint""" + if value is None: + return None + message = ("endpoint: host:port, with an IPv6 address in brackets, as" + " wg writes it") + if not isinstance(value, str) or ":" not in value: + return message + host, _, port = value.rpartition(":") + if not (port.isascii() and port.isdigit()) or not 1 <= int(port) <= 65535: + return message + if "%" in host: + return message + if host.startswith("[") and host.endswith("]"): + try: + ipaddress.IPv6Address(host[1:-1]) + except ValueError: + return message + return None + if ":" in host or not HOST_RE.fullmatch(host): + return message + return None + + +def validate_record(record: Any) -> list[str]: + """Every error of a record; empty when it is valid""" + if not isinstance(record, dict): + return ["record: must be a mapping"] + errors = [f"{key}: unknown field" for key in record if key not in FIELDS] + errors += [f"{key}: missing" for key in FIELDS if key not in record] + if errors: + return errors + checks = [ + label_error("set", record["set"]), + public_key_error(record["public_key"]), + endpoint_error(record["endpoint"]), + ] + checks += [label_error(key, record[key]) for key in OPTIONAL_LABELS + if record[key] is not None] + ts = record["ts"] + if isinstance(ts, bool) or not isinstance(ts, int) or not 0 < ts < MAX_TS: + checks.append("ts: seconds since the epoch, an integer") + errors = [error for error in checks if error] + return errors + overlay_errors(record["overlay"]) + + +def host_prefixes(overlay: list[str]) -> list[str]: + """What a peer routes to this node: each overlay address alone""" + prefixes = [] + for item in overlay: + address = ipaddress.ip_address(item) + prefixes.append(f"{address}/{address.max_prefixlen}") + return prefixes diff --git a/man/keel-cloud-api.8 b/man/keel-cloud-api.8 new file mode 100644 index 0000000..20e8342 --- /dev/null +++ b/man/keel-cloud-api.8 @@ -0,0 +1,34 @@ +.TH KEEL-CLOUD-API 8 "2026-10-02" "keel-cloud 0.1.0" "Keel Cloud" +.SH NAME +keel-cloud-api \- the Keel Cloud API service +.SH SYNOPSIS +.B keel-cloud-api +[\fB\-\-config\fR \fIFILE\fR] +.SH DESCRIPTION +The HTTP API of Keel Cloud, which tells the nodes of a set about each +other so they exchange WireGuard public keys and endpoints. It listens +with TLS only, on \fB[::]:8443\fR by default, and keeps its state in +SQLite: accounts, the SHA-256 of each API key, sets, and the public record +and entry secret proof of each node. It never holds an entry secret, a +node's private key or application data. +.PP +It runs as the unprivileged user \fBkeel-cloud\fR under the systemd unit +\fBkeel-cloud-api.service\fR. +.SH OPTIONS +.TP +.BR \-\-config " " \fIFILE\fR +The configuration file, \fI/etc/keel-cloud/api.conf\fR by default. +.SH FILES +.TP +.I /etc/keel-cloud/api.conf +listen address, port, certificate, private key and database. +.TP +.I /etc/keel-cloud/tls/ +the certificate and its key; the package makes a self-signed one for +development. +.TP +.I /var/lib/keel-cloud/cloud.db +the state. +.SH SEE ALSO +.BR keel-cloud (8), +.BR keel-cloud-node (8) diff --git a/man/keel-cloud-node.8 b/man/keel-cloud-node.8 new file mode 100644 index 0000000..8ccd661 --- /dev/null +++ b/man/keel-cloud-node.8 @@ -0,0 +1,50 @@ +.TH KEEL-CLOUD-NODE 8 "2026-10-02" "keel-cloud 0.1.0" "Keel Cloud" +.SH NAME +keel-cloud-node \- this node's side of Keel Cloud +.SH SYNOPSIS +.B keel-cloud-node +[\fB\-\-spec\fR \fIFILE\fR] [\fB\-\-state\-dir\fR \fIDIR\fR] \fIACTION\fR ... +.br +.B keel cloud +\fIACTION\fR ... +.SH DESCRIPTION +Reads the \fBcloud\fR section of the instance spec, registers this node in +its set with a proof built from the set's entry secret, checks the proofs +of the other nodes, and writes the peers the operator confirmed into the +spec's \fBnetwork.overlay.wireguard.peers\fR. It never writes WireGuard's +configuration: \fBkeel spec apply \-\-system\fR converges the spec, under the +confirmation window of \fBkeel network confirm\fR. +.PP +Admitted peers are pinned per set in \fI/var/lib/keel-cloud-node\fR: Keel +Cloud can bring a newer endpoint for a pinned key, never replace the key, +change its addresses or remove it. +.SH ACTIONS +.TP +.B entry-secret +print the set's entry secret, making it when its file does not exist. +.TP +.B enroll \fR[\fB\-\-endpoint\fR \fIHOST:PORT\fR] +register this node, or bring its record up to date. +.TP +.B sync \fR[\fB\-\-wait\fR \fISECONDS\fR] [\fB\-\-since\fR \fIREVISION\fR] +admit what the set proposes and write the pinned peers into the spec. +.TP +.B confirm \fIPUBLIC_KEY\fR \fB\-\-account\-key\-file\fR \fIFILE\fR +admit a pending node: a confirmation made here with the entry secret, +sent with the account key; every node checks it. +.TP +.B auto-admit on|off \fB\-\-account\-key\-file\fR \fIFILE\fR +let the set admit new nodes on their record proof alone, or stop. +.TP +.B forget \fIPUBLIC_KEY\fR +remove a peer from this node for good; Keel Cloud cannot bring it back. +.TP +.B status +the set as Keel Cloud and this node see it. +.TP +.B run +enroll, then long poll for changes; the \fBkeel-cloud-node.service\fR +unit, installed disabled. +.SH SEE ALSO +.BR keel (8), +.BR keel-cloud (8) diff --git a/man/keel-cloud.8 b/man/keel-cloud.8 new file mode 100644 index 0000000..3558acc --- /dev/null +++ b/man/keel-cloud.8 @@ -0,0 +1,42 @@ +.TH KEEL-CLOUD 8 "2026-10-02" "keel-cloud 0.1.0" "Keel Cloud" +.SH NAME +keel-cloud \- accounts, keys, sets and peers of a Keel Cloud instance +.SH SYNOPSIS +.B keel-cloud +[\fB\-\-config\fR \fIFILE\fR] [\fB\-\-database\fR \fIFILE\fR] +[\fB\-\-json\fR] \fINOUN\fR \fIVERB\fR ... +.SH DESCRIPTION +The command line of a Keel Cloud instance, run on the instance against +the service's database. Started as root, it runs as the \fBkeel-cloud\fR +user. A key is printed once, when it is made; only its hash is kept. +.SH COMMANDS +.TP +.B account create \fIACCOUNT\fR +make an account and print its first account key. +.TP +.B key create \fIACCOUNT\fR [\fB\-\-scope\fR enroll|account] [\fB\-\-label\fR \fITEXT\fR] +make a key, an enrollment key by default, and print it. +.TP +.B key list \fIACCOUNT\fR +the account's keys, never their values. +.TP +.B key revoke \fIACCOUNT\fR \fIID\fR +revoke a key. +.TP +.B set list \fIACCOUNT\fR +the account's sets. +.TP +.B set auto-admit \fIACCOUNT\fR \fISET\fR off +stop admitting new peers on their record proof alone. Turning it on, and +confirming a node, need the set's entry secret, so they are done on a +node of the set with \fBkeel cloud\fR. +.TP +.B peer list \fIACCOUNT\fR \fISET\fR +the set's nodes and their state. +.TP +.B peer remove \fIACCOUNT\fR \fISET\fR \fIPUBLIC_KEY\fR +forget a node; nodes that admitted it keep it until their operator +removes it. +.SH SEE ALSO +.BR keel-cloud-api (8), +.BR keel-cloud-node (8) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..61fb377 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "keel-cloud" +version = "0.1.0" +authors = [{name = "KeelLinux maintainers"}] +description = "Keel Cloud: membership and WireGuard key exchange for Keel nodes" +readme = "README.md" +requires-python = ">=3.12" +license = "GPL-3.0-or-later" +# The dependencies are Debian packages, named per binary package in +# debian/control: the node agent needs PyYAML and not aiohttp. +dependencies = [] + +[project.scripts] +keel-cloud = "keel_cloud.api.admin:main" +keel-cloud-api = "keel_cloud.api.server:main" +keel-cloud-node = "keel_cloud.node.cli:main" + +[project.urls] +Homepage = "https://github.com/Keel-Linux/keel-cloud" + +[tool.setuptools] +packages = ["keel_cloud", "keel_cloud.api", "keel_cloud.node"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +# A test that cannot run fails rather than skips: a skipped suite would +# pass the coverage gate on code nothing ran. +filterwarnings = ["error::pytest.PytestUnhandledCoroutineWarning"] + +[tool.coverage.run] +branch = true +source = ["keel_cloud"] + +[tool.coverage.report] +fail_under = 95 +show_missing = true diff --git a/scripts/make-dev-certificate b/scripts/make-dev-certificate new file mode 100755 index 0000000..8b85b01 --- /dev/null +++ b/scripts/make-dev-certificate @@ -0,0 +1,32 @@ +#!/bin/sh +# make-dev-certificate [NAME [ADDRESS...]] +# +# A self-signed certificate for a development instance of Keel Cloud, in +# /etc/keel-cloud/tls: an ECDSA P-256 key readable by the keel-cloud group +# only, and a certificate for NAME (default: this machine's name) and each +# ADDRESS. Nodes trust it through cloud.ca_file in their spec. A production +# instance uses a certificate from ACME instead (README.md, "TLS"). +set -eu + +dir=/etc/keel-cloud/tls +name=${1:-$(hostname -f 2>/dev/null || hostname)} +[ $# -gt 0 ] && shift +san="DNS:$name" +for address in "$@"; do + san="$san,IP:$address" +done + +umask 077 +mkdir -p "$dir" +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ + -days 397 -subj "/CN=$name" -addext "subjectAltName=$san" \ + -keyout "$dir/key.pem.new" -out "$dir/cert.pem.new" 2>/dev/null +chgrp keel-cloud "$dir/key.pem.new" +chmod 0640 "$dir/key.pem.new" +chmod 0644 "$dir/cert.pem.new" +chmod 0755 "$dir" +mv "$dir/key.pem.new" "$dir/key.pem" +mv "$dir/cert.pem.new" "$dir/cert.pem" +# What the package's purge may remove: only this certificate, by its hash +(cd "$dir" && sha256sum cert.pem > .made-by-make-dev-certificate) +echo "make-dev-certificate: $dir/cert.pem for $san" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4b22d97 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""Shared helpers: WireGuard-shaped keys, records, a TLS certificate""" + +import base64 +import os +import subprocess + +import pytest + +from keel_cloud.proof import make_proof, new_entry_secret, parse_entry_secret + +NOW = 1_790_000_000 + + +def wg_key() -> str: + """A key as wg pubkey prints it: 32 random bytes in base64""" + return base64.b64encode(os.urandom(32)).decode() + + +def make_record(**changes) -> dict: + record = {"set": "shop", "public_key": wg_key(), + "overlay": ["fd00:6b65:c1::1"], + "endpoint": "[2001:db8::10]:51820", "appliance": "core", + "role": None, "site": None, "ts": NOW} + record.update(changes) + return record + + +@pytest.fixture +def secret() -> bytes: + return parse_entry_secret(new_entry_secret()) + + +@pytest.fixture +def proven(secret): + """make(**changes) -> (record, proof) proven with the set's secret""" + def make(**changes): + record = make_record(**changes) + return record, make_proof(secret, record) + return make + + +@pytest.fixture(scope="session") +def certificate(tmp_path_factory) -> tuple[str, str]: + """A self-signed certificate for localhost and ::1, made by openssl""" + directory = tmp_path_factory.mktemp("tls") + cert, key = directory / "cert.pem", directory / "key.pem" + subprocess.run( + ["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", + "ec_paramgen_curve:prime256v1", "-nodes", "-days", "2", + "-subj", "/CN=localhost", "-addext", + "subjectAltName=DNS:localhost,IP:::1,IP:127.0.0.1", + "-keyout", str(key), "-out", str(cert)], + check=True, capture_output=True) + return str(cert), str(key) diff --git a/tests/nodes.py b/tests/nodes.py new file mode 100644 index 0000000..0477828 --- /dev/null +++ b/tests/nodes.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""A node on disk for the agent's tests: a spec, its secrets, a fake +machine that answers keel and ip as a Keel node would""" + +import io +import json +import os +import subprocess +from dataclasses import dataclass, field + +import yaml + +from conftest import NOW, wg_key +from keel_cloud.node.agent import System +from keel_cloud.proof import new_entry_secret + +ADDRESSES = [ + {"ifname": "lo", "addr_info": []}, + {"ifname": "eth0", "addr_info": [ + {"family": "inet6", "local": "2001:db8::99", "temporary": True}, + {"family": "inet6", "local": "fd42::10"}, + {"family": "inet", "local": "10.0.3.10"}, + {"family": "inet6", "local": "2001:db8::10"}, + {}]}, + {"ifname": "wg0", "addr_info": [{"family": "inet6", + "local": "2001:db8::77"}]}, +] + + +def spec_doc(address: str, endpoint: str, *, peers=None) -> dict: + wireguard = {"interface": "wg0", "address": address, + "listen_port": 51820, "peers": peers or []} + return { + "version": 1, + "network": {"managed_by": "host", "overlay": {"wireguard": + wireguard}}, + "appliance": {"name": "core"}, + "database": {"server": {"role": "primary"}}, + "cloud": {"endpoint": endpoint, + "api_key": {"file": "API_KEY"}, + "entry_secret": {"file": "ENTRY_SECRET"}, + "set": "shop"}, + } + + +@dataclass +class FakeMachine: + """keel network wireguard key, keel spec validate and ip -j""" + key: str = field(default_factory=wg_key) + addresses: list = field(default_factory=lambda: ADDRESSES) + refuse_spec: str = "" + calls: list = field(default_factory=list) + + def __call__(self, argv): + self.calls.append(argv) + if argv[:4] == ["keel", "network", "wireguard", "key"]: + return subprocess.CompletedProcess(argv, 0, self.key + "\n", "") + if argv[:3] == ["keel", "spec", "validate"]: + code = 2 if self.refuse_spec else 0 + return subprocess.CompletedProcess(argv, code, "", + self.refuse_spec) + if argv[0] == "ip": + return subprocess.CompletedProcess( + argv, 0, json.dumps(self.addresses), "") + raise AssertionError(argv) + + +@dataclass +class Node: + root: str + machine: FakeMachine + system: System + spec_path: str + entry_secret_path: str + api_key_path: str + state_dir: str + + def doc(self) -> dict: + with open(self.spec_path, encoding="utf-8") as stream: + return yaml.safe_load(stream) + + def peers(self) -> list: + return self.doc()["network"]["overlay"]["wireguard"]["peers"] + + def output(self) -> str: + return self.system.out.getvalue() + + +def make_node(root, name: str, address: str, endpoint: str, api_key: str, + entry_secret: str | None = None, clock=lambda: NOW, + ca_file: str | None = None) -> Node: + directory = os.path.join(str(root), name) + os.makedirs(os.path.join(directory, "secrets"), mode=0o700) + entry_path = os.path.join(directory, "secrets", "entry_secret") + key_path = os.path.join(directory, "secrets", "api_key") + for path, value in ((entry_path, entry_secret or new_entry_secret()), + (key_path, api_key)): + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT, 0o600) + with os.fdopen(descriptor, "w") as stream: + stream.write(value + "\n") + doc = spec_doc(address, endpoint) + doc["cloud"]["api_key"]["file"] = key_path + doc["cloud"]["entry_secret"]["file"] = entry_path + if ca_file: + doc["cloud"]["ca_file"] = ca_file + spec_path = os.path.join(directory, "instance.yaml") + with open(spec_path, "w", encoding="utf-8") as stream: + yaml.safe_dump(doc, stream, sort_keys=False) + os.chmod(spec_path, 0o600) + machine = FakeMachine() + system = System(run=machine, clock=clock, sleep=lambda s: None, + out=io.StringIO()) + return Node(directory, machine, system, spec_path, entry_path, key_path, + os.path.join(directory, "state")) diff --git a/tests/package-smoke.sh b/tests/package-smoke.sh new file mode 100644 index 0000000..0318692 --- /dev/null +++ b/tests/package-smoke.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# package-smoke.sh DIR: install python3-keel-cloud and keel-cloud-api from +# DIR on this trixie system and check the service the package starts: +# its own user, [::]:8443, TLS only, a key made by the command line works +# and only its hash is stored. Run as root in a throwaway container (CI). +set -euo pipefail + +dir=$(realpath "$1") # apt reads a bare relative path as a package name +fail() { echo "package-smoke: $*" >&2; exit 1; } + +apt-get install -y -qq --no-install-recommends procps iproute2 curl \ + "$dir"/python3-keel-cloud_*_all.deb "$dir"/keel-cloud-api_*_all.deb + +for i in $(seq 1 30); do + systemctl is-active --quiet keel-cloud-api && break + sleep 1 +done +systemctl is-active --quiet keel-cloud-api \ + || { journalctl -u keel-cloud-api --no-pager | tail -20; fail "not active"; } + +user=$(ps -o user= -p "$(systemctl show -p MainPID --value keel-cloud-api)") +[ "$user" = keel-cloud ] || fail "runs as $user, not keel-cloud" +ss -ltnH | grep -q '\[::\]:8443' || fail "not listening on [::]:8443" + +[ "$(stat -c '%U:%G %a' /etc/keel-cloud/tls/key.pem)" = "root:keel-cloud 640" ] \ + || fail "the TLS key is not root:keel-cloud 0640" + +code=$(curl -s -o /dev/null -w '%{http_code}' "http://[::1]:8443/v1/health" \ + || true) +[ "$code" = 000 ] || fail "plain HTTP answered $code" + +name=$(hostname -f 2>/dev/null || hostname) +curl -sf --cacert /etc/keel-cloud/tls/cert.pem \ + --resolve "$name:8443:[::1]" "https://$name:8443/v1/health" \ + | grep -q '"ok"' || fail "no health over TLS" + +account=$(keel-cloud account create smoke) +enroll=$(keel-cloud key create smoke) +[[ $enroll == kc1e_* ]] || fail "no enrollment key" +status=$(curl -s -o /dev/null -w '%{http_code}' --cacert /etc/keel-cloud/tls/cert.pem \ + --resolve "$name:8443:[::1]" -H "Authorization: Bearer $account" \ + "https://$name:8443/v1/sets") +[ "$status" = 200 ] || fail "the account key was refused: $status" +if grep -rqF -e "${enroll#kc1e_}" /var/lib/keel-cloud; then + fail "a plain key is in the database" +fi +[ "$(stat -c %U /var/lib/keel-cloud/cloud.db)" = keel-cloud ] \ + || fail "the database is not the service's" + +apt-get purge -y -qq keel-cloud-api +[ ! -e /var/lib/keel-cloud ] || fail "purge left the state" +[ ! -e /etc/keel-cloud/tls/key.pem ] || fail "purge left the made key" + +echo "package-smoke: keel-cloud-api runs as keel-cloud on [::]:8443, TLS only" diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..d929930 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,397 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The node agent against a real service over TLS on [::1] + +Two nodes of one set, a service in a thread with the session's +self-signed certificate, and the agents' real HTTPS client. Only the +machine is fake: keel and ip answer from tests/nodes.py. +""" + +import asyncio +import os +import socket +import threading + +import pytest + +from conftest import NOW +from keel_cloud.api.app import make_app +from keel_cloud.api.server import tls_context +from keel_cloud.api.store import Store +from keel_cloud.node import NodeError +from keel_cloud.node.agent import APPLY_HINT, Agent, endpoint_address +from keel_cloud.node.client import Client, CloudError +from keel_cloud.node.spec import Overlay +from keel_cloud.proof import new_entry_secret +from nodes import FakeMachine, make_node +from aiohttp import web + + +class Service: + """The API on [::1], TLS, in a thread of its own""" + + def __init__(self, store: Store, certificate): + self.store = store + self.context = tls_context(*certificate) + self.loop = asyncio.new_event_loop() + self.started = threading.Event() + sock = socket.socket(socket.AF_INET6) + sock.bind(("::1", 0)) + self.port = sock.getsockname()[1] + self.sock = sock + self.thread = threading.Thread(target=self._serve, daemon=True) + + def _serve(self): + asyncio.set_event_loop(self.loop) + self.runner = web.AppRunner(make_app(self.store, 0.02)) + self.loop.run_until_complete(self.runner.setup()) + site = web.SockSite(self.runner, self.sock, ssl_context=self.context) + self.loop.run_until_complete(site.start()) + self.started.set() + self.loop.run_forever() + + def __enter__(self): + self.thread.start() + self.started.wait(5) + return self + + def __exit__(self, *exc): + asyncio.run_coroutine_threadsafe(self.runner.cleanup(), + self.loop).result(5) + self.loop.call_soon_threadsafe(self.loop.stop) + self.thread.join(5) + + @property + def url(self) -> str: + return f"https://localhost:{self.port}" + + +@pytest.fixture +def cloud(tmp_path, certificate): + store = Store(str(tmp_path / "cloud.db"), clock=lambda: NOW) + account = store.create_account("acme") + enroll = store.create_key(store.account_id("acme"), "enroll") + with Service(store, certificate) as service: + service.account_key, service.enroll_key = account, enroll + yield service + store.close() + + +def agent_for(node) -> Agent: + return Agent(node.spec_path, node.state_dir, node.system, + secret_owner=os.getuid(), wait=1) + + +@pytest.fixture +def pair(tmp_path, cloud, certificate): + secret = new_entry_secret() + nodes = [make_node(tmp_path, name, f"fd00:6b65:c1::{i}/64", cloud.url, + cloud.enroll_key, secret, ca_file=certificate[0]) + for i, name in ((1, "a"), (2, "b"))] + return nodes, [agent_for(n) for n in nodes] + + +def account_key_file(tmp_path, cloud) -> str: + path = tmp_path / "account_key" + path.write_text(cloud.account_key) + os.chmod(path, 0o600) + return str(path) + + +def test_two_nodes_enroll_wait_for_the_operator_then_pin_each_other( + tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + assert "registered" in a.output() and ": pending" in a.output() + agent_a.sync() + assert a.peers() == [] + assert "waiting for the operator's confirmation" in a.output() + + account_file = account_key_file(tmp_path, cloud) + agent_a.confirm(b.machine.key, account_file) + agent_a.confirm(a.machine.key, account_file) + revision = agent_a.sync() + agent_b.sync() + + assert a.peers() == [{"public_key": b.machine.key, + "endpoint": "[2001:db8::10]:51820", + "allowed_ips": ["fd00:6b65:c1::2/128"], + "persistent_keepalive": 25}] + assert b.peers()[0]["public_key"] == a.machine.key + assert APPLY_HINT in a.output() + assert revision == 4 + validate = [c for c in a.machine.calls if c[:3] == ["keel", "spec", + "validate"]] + assert validate and validate[0][-1].endswith(".instance.yaml.cloud") + + +def test_a_node_with_the_wrong_entry_secret_is_refused_by_the_set( + tmp_path, cloud, pair, certificate): + (a, _), (agent_a, _) = pair + intruder = make_node(tmp_path, "c", "fd00:6b65:c1::3/64", cloud.url, + cloud.enroll_key, ca_file=certificate[0]) + agent_c = agent_for(intruder) + agent_a.enroll() + agent_c.enroll() + with pytest.raises(NodeError, match="not confirmed"): + agent_a.confirm(intruder.machine.key, + account_key_file(tmp_path, cloud)) + # Confirmed by the intruder itself, with its own secret: still refused + agent_c.confirm(intruder.machine.key, account_key_file(tmp_path, cloud)) + agent_a.sync() + assert a.peers() == [] + assert "does not verify" in a.output() + + +def test_automatic_admission_turned_on_from_a_node(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + agent_a.auto_admit(True, account_key_file(tmp_path, cloud)) + agent_a.sync() + assert a.peers()[0]["public_key"] == b.machine.key + agent_a.auto_admit(False, account_key_file(tmp_path, cloud)) + assert "automatic admission off" in a.output() + + +def test_automatic_admission_the_service_turned_on_is_ignored( + tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + cloud.store.set_auto_admit(1, "shop", "d" * 64) + agent_a.sync() + assert a.peers() == [] + + +def test_forget_removes_a_peer_for_good(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + agent_a.confirm(b.machine.key, account_key_file(tmp_path, cloud)) + agent_a.sync() + assert len(a.peers()) == 1 + agent_a.forget(b.machine.key) + assert a.peers() == [] and "forgot" in a.output() + agent_b.system.clock = lambda: NOW + 5 + agent_b.enroll() + agent_a.sync() + assert a.peers() == [] + agent_a.forget(b.machine.key) + with pytest.raises(NodeError, match="public_key"): + agent_a.forget("nonsense") + + +def test_a_peer_declared_by_hand_is_left_as_it_is(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + doc = a.doc() + doc["network"]["overlay"]["wireguard"]["peers"] = [ + {"public_key": b.machine.key, "allowed_ips": ["fd00:6b65:c1::2/128"]}] + import yaml + with open(a.spec_path, "w") as stream: + yaml.safe_dump(doc, stream) + agent_a.confirm(b.machine.key, account_key_file(tmp_path, cloud)) + agent_a.sync() + assert a.peers() == [{"public_key": b.machine.key, + "allowed_ips": ["fd00:6b65:c1::2/128"]}] + + +def test_the_status_names_this_node_pinned_and_pending(tmp_path, cloud, + pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + agent_a.status() + assert "this node" in a.output() and "pending" in a.output() + + +def test_sync_long_polls_from_a_revision(cloud, pair): + (a, _), (agent_a, _) = pair + agent_a.enroll() + assert agent_a.sync(since=1, wait=1) == 1 + + +def test_a_spec_keel_refuses_is_not_written(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_a.enroll() + agent_b.enroll() + account_file = account_key_file(tmp_path, cloud) + agent_a.confirm(b.machine.key, account_file) + a.machine.refuse_spec = "network.overlay.wireguard.peers: bad" + with pytest.raises(NodeError, match="keel refuses"): + agent_a.sync() + assert a.peers() == [] + a.machine.refuse_spec = "" + agent_a.sync() + assert len(a.peers()) == 1 + + +def test_the_wrong_ca_is_refused(tmp_path, cloud, pair): + (a, _), (agent_a, _) = pair + doc = a.doc() + doc["cloud"].pop("ca_file") + import yaml + with open(a.spec_path, "w") as stream: + yaml.safe_dump(doc, stream) + with pytest.raises(CloudError, match="CERTIFICATE_VERIFY_FAILED"): + agent_a.enroll() + + +def test_an_enrollment_key_cannot_confirm(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_b.enroll() + with pytest.raises(CloudError, match="scope") as refused: + agent_a.confirm(b.machine.key, b.api_key_path) + assert refused.value.status == 403 + + +def test_run_enrolls_once_and_follows_changes(tmp_path, cloud, pair): + (a, b), (agent_a, agent_b) = pair + agent_b.enroll() + agent_a.confirm(b.machine.key, account_key_file(tmp_path, cloud)) + agent_a.run(rounds=2) + assert a.output().count("registered") == 1 + assert a.peers()[0]["public_key"] == b.machine.key + + +def test_run_retries_after_a_failure(tmp_path, pair): + (a, _), (agent_a, _) = pair + os.unlink(a.api_key_path) + slept = [] + a.system.sleep = slept.append + agent_a.run(rounds=1) + assert slept == [60] and "again in 60s" in a.output() + + +def test_a_bad_secret_and_a_bad_record_are_reported(tmp_path, pair): + (a, _), (agent_a, _) = pair + with open(a.entry_secret_path, "w") as stream: + stream.write("short\n") + with pytest.raises(NodeError, match="at least 43"): + agent_a.enroll() + + +def test_a_record_keel_cannot_make_is_reported(tmp_path, cloud, pair): + (a, _), (agent_a, _) = pair + doc = a.doc() + doc["network"]["overlay"]["wireguard"]["address"] = "2001:db8::1/64" + import yaml + with open(a.spec_path, "w") as stream: + yaml.safe_dump(doc, stream) + with pytest.raises(NodeError, match="this node's record"): + agent_a.enroll() + + +class Garbage: + """A service that answers listings no node can read""" + + def __init__(self, answer): + self.answer = answer + + def __call__(self, *args): + return self + + def peers(self, *args): + return self.answer + + def register(self, *args): + return self.answer + + +@pytest.mark.parametrize("answer", [[], {"revision": "1", "nodes": []}, + {"revision": True, "nodes": []}, + {"revision": -1, "nodes": []}, + {"revision": 1, "nodes": {}}]) +def test_a_listing_this_node_cannot_read_stops_the_round(tmp_path, pair, + answer): + (a, _), _ = pair + agent = Agent(a.spec_path, a.state_dir, a.system, + client_factory=Garbage(answer), secret_owner=os.getuid()) + with pytest.raises(CloudError, match="cannot read"): + agent.sync() + + +def test_run_survives_a_service_that_breaks_the_rules(tmp_path, pair): + (a, _), _ = pair + agent = Agent(a.spec_path, a.state_dir, a.system, + client_factory=Garbage(None), secret_owner=os.getuid()) + a.machine.addresses = "not json" + agent.run(rounds=1) + assert "again in 60s" in a.output() + + +def test_confirm_needs_the_node_in_the_set(tmp_path, cloud, pair): + (a, b), (agent_a, _) = pair + agent_a.enroll() + with pytest.raises(NodeError, match="no such node"): + agent_a.confirm(b.machine.key, account_key_file(tmp_path, cloud)) + + +def test_confirm_checks_the_key_first(tmp_path, pair): + (a, _), (agent_a, _) = pair + with pytest.raises(NodeError, match="public_key"): + agent_a.confirm("nonsense", a.api_key_path) + + +def test_entry_secret_is_made_once_and_printed(tmp_path, pair): + (a, _), (agent_a, _) = pair + os.unlink(a.entry_secret_path) + agent_a.entry_secret() + made = open(a.entry_secret_path).read().strip() + assert "made a new entry secret" in a.output() + assert oct(os.stat(a.entry_secret_path).st_mode & 0o777) == "0o600" + agent_a.entry_secret() + assert a.output().count(made) == 2 + assert a.output().count("made a new") == 1 + with open(a.entry_secret_path, "w") as stream: + stream.write("short\n") + with pytest.raises(NodeError, match="at least 43"): + agent_a.entry_secret() + + +def test_keel_must_print_a_key(tmp_path, pair): + (a, _), (agent_a, _) = pair + a.machine.key = "no key" + with pytest.raises(NodeError, match="keel network wireguard key"): + agent_a.enroll() + + +def overlay() -> Overlay: + return Overlay("wg0", ["fd00::1"], 51820) + + +def test_the_endpoint_is_ipv6_first_global_first(tmp_path): + machine = FakeMachine() + system = make_node(tmp_path, "n", "fd00::1/64", "https://x", "k").system + system.run = machine + assert endpoint_address(system, overlay()) == "[2001:db8::10]:51820" + machine.addresses = [{"ifname": "eth0", "addr_info": [ + {"local": "192.168.1.5"}, {"local": "100.1.2.3"}]}] + assert endpoint_address(system, overlay()) == "100.1.2.3:51820" + machine.addresses = [{"ifname": "eth0", "addr_info": [ + {"local": "192.168.1.5"}, {"local": "fd42::5"}]}] + assert endpoint_address(system, overlay()) == "[fd42::5]:51820" + machine.addresses = [] + assert endpoint_address(system, overlay()) is None + machine.addresses = ["odd", {"ifname": "eth0", "addr_info": [ + "odd", {"local": "not an address"}, {"local": "2001:db8::5"}]}] + assert endpoint_address(system, overlay()) == "[2001:db8::5]:51820" + + +def test_no_endpoint_when_ip_fails(tmp_path): + import subprocess + system = make_node(tmp_path, "n", "fd00::1/64", "https://x", "k").system + system.run = lambda argv: subprocess.CompletedProcess(argv, 0, "{", "") + assert endpoint_address(system, overlay()) is None + system.run = lambda argv: subprocess.CompletedProcess(argv, 1, "", "") + assert endpoint_address(system, overlay()) is None + + +def test_the_client_reports_unreachable_and_non_json_answers(): + client = Client("https://[::1]:1/", "key") + with pytest.raises(CloudError) as refused: + client.peers("shop") + assert refused.value.status is None + assert client.endpoint == "https://[::1]:1" diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..5065438 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,269 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The HTTP API: scopes, enrollment, listing, long poll, confirmation""" + +import asyncio + +import pytest + +from conftest import NOW +from keel_cloud.api.app import make_app +from keel_cloud.api.limits import FailureLimiter +from keel_cloud.api.store import Store + + +@pytest.fixture +def store(): + made = Store(":memory:", clock=lambda: NOW) + yield made + made.close() + + +@pytest.fixture +def keys(store): + account = store.create_account("acme") + enroll = store.create_key(store.account_id("acme"), "enroll") + return {"account": account, "enroll": enroll} + + +@pytest.fixture +async def api(aiohttp_client, store): + return await aiohttp_client(make_app(store, poll_interval=0.01)) + + +def auth(key: str) -> dict: + return {"Authorization": f"Bearer {key}"} + + +async def enroll(api, key, record, proof, set_name="shop"): + return await api.post(f"/v1/sets/{set_name}/peers", headers=auth(key), + json={"record": record, "proof": proof}) + + +async def test_health_needs_no_key(api): + response = await api.get("/v1/health") + assert response.status == 200 + assert (await response.json())["status"] == "ok" + + +async def test_every_other_path_needs_a_valid_key(api): + for headers in ({}, auth("kc1e_" + "a" * 43), {"Authorization": "x"}): + response = await api.get("/v1/sets/shop/peers", headers=headers) + assert response.status == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert "error" in await response.json() + + +async def test_failed_keys_are_limited_per_client(aiohttp_client, store): + limiter = FailureLimiter(limit=2) + api = await aiohttp_client(make_app(store, limiter=limiter)) + for status in (401, 401, 429): + response = await api.get("/v1/sets", headers=auth("bad")) + assert response.status == status + + +async def test_a_node_enrolls_and_lists_its_set(api, keys, proven): + record, proof = proven() + response = await enroll(api, keys["enroll"], record, proof) + assert response.status == 201 + assert await response.json() == {"set": "shop", "status": "pending", + "revision": 1} + listing = await (await api.get("/v1/sets/shop/peers", + headers=auth(keys["enroll"]))).json() + assert listing["revision"] == 1 and not listing["auto_admit"] + assert listing["auto_admit_proof"] is None + node = listing["nodes"][0] + assert node == {"record": record, "proof": proof, "status": "pending", + "confirmation": None} + + +async def test_an_update_answers_200(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await enroll(api, keys["enroll"], {**record, "ts": NOW + 1}, + proof) + assert response.status == 200 + + +@pytest.mark.parametrize("body,fragment", [ + ({"record": {}}, "the body is"), + ({"record": {"set": "shop"}, "proof": "a" * 64}, "missing"), +]) +async def test_enrollment_refuses_malformed_bodies(api, keys, body, + fragment): + response = await api.post("/v1/sets/shop/peers", + headers=auth(keys["enroll"]), json=body) + assert response.status == 400 + assert fragment in (await response.json())["error"] + + +async def test_enrollment_refuses_a_bad_proof_shape(api, keys, proven): + record, _ = proven() + response = await enroll(api, keys["enroll"], record, "nope") + assert response.status == 400 + assert "proof" in (await response.json())["error"] + + +async def test_enrollment_refuses_another_set_and_a_far_clock(api, keys, + proven): + record, proof = proven(set="other") + response = await enroll(api, keys["enroll"], record, proof) + assert "another set" in (await response.json())["error"] + record, proof = proven(ts=NOW - 301) + response = await enroll(api, keys["enroll"], record, proof) + assert "seconds from the service's clock" in ( + await response.json())["error"] + + +async def test_conflicts_are_409(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await enroll(api, keys["enroll"], record, proof) + assert response.status == 409 + + +async def test_bodies_must_be_json_objects(api, keys): + for data in (b"not json", b"[1]"): + response = await api.post("/v1/keys", headers={ + **auth(keys["account"]), "Content-Type": "application/json"}, + data=data) + assert response.status == 400 + + +async def test_set_names_are_labels(api, keys): + response = await api.get("/v1/sets/Not_A_Label/peers", + headers=auth(keys["enroll"])) + assert response.status == 400 + + +async def test_an_unknown_set_is_404_and_unknown_paths_are_json(api, keys): + response = await api.get("/v1/sets/none/peers", + headers=auth(keys["enroll"])) + assert response.status == 404 + response = await api.get("/v2/anything") + assert response.status == 404 + assert (await response.json())["error"] == "Not Found" + + +async def test_the_enrollment_key_cannot_manage_or_confirm(api, keys, + proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + calls = [api.post("/v1/keys", headers=auth(keys["enroll"]), json={}), + api.get("/v1/sets", headers=auth(keys["enroll"])), + api.patch("/v1/sets/shop", headers=auth(keys["enroll"]), + json={"auto_admit": False}), + api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["enroll"]), + json={"public_key": record["public_key"], + "confirmation": "c" * 64})] + for call in calls: + assert (await call).status == 403 + + +async def confirm(api, key, public_key, confirmation="c" * 64): + return await api.post("/v1/sets/shop/peers/confirm", headers=auth(key), + json={"public_key": public_key, + "confirmation": confirmation}) + + +async def test_the_operator_confirms_with_the_account_key(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await confirm(api, keys["account"], record["public_key"]) + assert response.status == 200 + assert (await response.json())["status"] == "confirmed" + listing = await (await api.get("/v1/sets/shop/peers", + headers=auth(keys["enroll"]))).json() + assert listing["nodes"][0]["confirmation"] == "c" * 64 + + +async def test_confirm_checks_its_body_and_the_node(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + for public_key, confirmation in (("x", "c" * 64), + (record["public_key"], "nope")): + response = await confirm(api, keys["account"], public_key, + confirmation) + assert response.status == 400 + response = await api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["account"]), + json={"public_key": record["public_key"]}) + assert response.status == 400 + response = await confirm(api, keys["account"], + proven()[0]["public_key"]) + assert response.status == 404 + + +@pytest.mark.parametrize("body", [ + {"auto_admit": "yes"}, {"auto_admit": True}, + {"auto_admit": True, "proof": "nope"}, + {"auto_admit": False, "proof": "d" * 64}]) +async def test_auto_admit_on_needs_a_proof(api, keys, proven, body): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await api.patch("/v1/sets/shop", + headers=auth(keys["account"]), json=body) + assert response.status == 400 + + +async def test_auto_admit_is_relayed_with_its_proof(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await api.patch("/v1/sets/shop", + headers=auth(keys["account"]), + json={"auto_admit": True, "proof": "d" * 64}) + assert (await response.json())["auto_admit"] is True + listing = await (await api.get("/v1/sets", + headers=auth(keys["account"]))).json() + assert listing["sets"][0]["auto_admit"] is True + peers = await (await api.get("/v1/sets/shop/peers", + headers=auth(keys["enroll"]))).json() + assert peers["auto_admit_proof"] == "d" * 64 + response = await api.patch("/v1/sets/shop", + headers=auth(keys["account"]), + json={"auto_admit": False}) + assert (await response.json())["auto_admit"] is False + + +async def test_the_account_key_makes_enrollment_keys(api, keys, store): + response = await api.post("/v1/keys", headers=auth(keys["account"]), + json={"label": "node b"}) + made = await response.json() + assert response.status == 201 and made["scope"] == "enroll" + assert store.authenticate(made["key"]).scope == "enroll" + + +async def test_long_poll_returns_when_the_set_changes(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + poll = asyncio.ensure_future(api.get( + "/v1/sets/shop/peers?since=1&wait=5", headers=auth(keys["enroll"]))) + await asyncio.sleep(0.05) + assert not poll.done() + await confirm(api, keys["account"], record["public_key"]) + listing = await (await asyncio.wait_for(poll, 2)).json() + assert listing["revision"] == 2 + + +async def test_long_poll_times_out_with_the_same_listing(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await api.get("/v1/sets/shop/peers?since=1&wait=1", + headers=auth(keys["enroll"])) + assert (await response.json())["revision"] == 1 + + +@pytest.mark.parametrize("query", ["since=-1", "wait=56", "wait=x", + "since=\u00b2"]) +async def test_long_poll_parameters_are_bounded(api, keys, proven, query): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await api.get(f"/v1/sets/shop/peers?{query}", + headers=auth(keys["enroll"])) + assert response.status == 400 + + +async def test_bodies_are_limited_in_size(api, keys): + response = await api.post("/v1/keys", headers=auth(keys["account"]), + json={"label": "x" * 20000}) + assert response.status == 413 diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..01fbe82 --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,179 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The formats both sides share: records, proofs and API keys""" + +import pytest + +from conftest import make_record, wg_key +from keel_cloud import keys, proof +from keel_cloud.record import ( + endpoint_error, + host_prefixes, + overlay_errors, + public_key_error, + validate_record, +) + + +def test_a_complete_record_is_valid(): + assert validate_record(make_record()) == [] + + +def test_a_record_with_ipv4_overlay_and_labels_is_valid(): + record = make_record(overlay=["fd00::2", "10.9.0.2"], role="primary", + site="site-1", endpoint=None) + assert validate_record(record) == [] + + +def test_record_must_be_a_mapping_with_every_field_and_no_other(): + assert validate_record([]) == ["record: must be a mapping"] + record = make_record(extra=1) + del record["ts"] + assert validate_record(record) == ["extra: unknown field", "ts: missing"] + + +@pytest.mark.parametrize("field,value,fragment", [ + ("set", "Shop", "set: must be a lower case DNS label"), + ("public_key", "not-a-key", "public_key: not a WireGuard"), + ("endpoint", "2001:db8::1:51820", "endpoint: host:port"), + ("appliance", "-bad", "appliance: must be"), + ("ts", True, "ts: seconds"), + ("ts", "1", "ts: seconds"), + ("ts", 0, "ts: seconds"), +]) +def test_each_bad_field_is_named(field, value, fragment): + errors = validate_record(make_record(**{field: value})) + assert any(fragment in error for error in errors), errors + + +def test_public_keys_have_the_shape_wg_prints(): + assert public_key_error(wg_key()) is None + # 32 bytes leave the last base64 character with 4 bits: "B" is not one + assert public_key_error("A" * 42 + "B=") is not None + assert public_key_error(None) is not None + + +@pytest.mark.parametrize("field,value", [ + ("public_key", wg_key() + "\n"), ("set", "shop\n"), + ("endpoint", "a.example.org\n:51820"), ("site", "a\n")]) +def test_a_trailing_newline_is_not_the_same_value(field, value): + """Otherwise one key could be pinned twice under two spellings""" + assert validate_record(make_record(**{field: value})) + + +def test_scopes_and_non_ascii_digits_are_refused(): + assert overlay_errors(["fd00::1%eth0"]) + assert endpoint_error("[fe80::1%eth0]:51820") is not None + assert endpoint_error("host:²") is not None + assert keys.key_scope(keys.new_key(keys.ENROLL) + "\n") is None + assert proof.proof_error("a" * 64 + "\n") is not None + + +@pytest.mark.parametrize("value", [ + None, "[2001:db8::1]:51820", "192.0.2.1:51820", "node.example.org:1"]) +def test_good_endpoints(value): + assert endpoint_error(value) is None + + +@pytest.mark.parametrize("value", [ + 5, "no-port", "[2001:db8::1]:0", "[2001:db8::1]:x", "[nothing]:51820", + "2001:db8::1:51820", "bad_host:51820", "host:70000"]) +def test_bad_endpoints(value): + assert endpoint_error(value) is not None + + +@pytest.mark.parametrize("overlay,fragment", [ + ("fd00::1", "a list"), + ([], "a list"), + (["2001:db8::1"], "unique local"), + (["fd00::1", "192.0.2.1"], "private"), + (["fd00:0::1"], "write it as fd00::1"), + (["10.0.0.1"], "the IPv6 address first"), + (["fd00::1", "fd00::2"], "one per family"), + ([5], "not a string"), + (["nonsense"], "does not appear"), +]) +def test_overlay_rules(overlay, fragment): + errors = overlay_errors(overlay) + assert any(fragment in error for error in errors), errors + + +def test_host_prefixes_route_each_address_alone(): + assert host_prefixes(["fd00::1", "10.0.0.1"]) == ["fd00::1/128", + "10.0.0.1/32"] + + +def test_a_proof_verifies_only_with_the_same_secret_and_record(secret): + record = make_record() + made = proof.make_proof(secret, record) + assert proof.verify_proof(secret, record, made) + other = proof.parse_entry_secret(proof.new_entry_secret()) + assert not proof.verify_proof(other, record, made) + assert not proof.verify_proof(secret, {**record, "endpoint": None}, made) + assert not proof.verify_proof(secret, record, made.upper()) + assert not proof.verify_proof(secret, record, None) + + +@pytest.mark.parametrize("field,value", [ + ("set", "other"), ("public_key", wg_key()), ("overlay", ["fd00::9"]), + ("endpoint", "[2001:db8::99]:1"), ("appliance", "web"), + ("role", "replica"), ("site", "b"), ("ts", 1)]) +def test_the_proof_covers_every_field(secret, field, value): + record = make_record() + made = proof.make_proof(secret, record) + assert not proof.verify_proof(secret, {**record, field: value}, made) + + +def test_confirmation_covers_set_key_and_addresses_only(secret): + record = make_record() + made = proof.make_confirmation(secret, record) + assert proof.verify_confirmation(secret, {**record, "ts": 1, + "endpoint": None}, made) + for field, value in (("set", "other"), ("public_key", wg_key()), + ("overlay", ["fd00::9"])): + assert not proof.verify_confirmation(secret, + {**record, field: value}, made) + + +def test_the_three_proofs_never_stand_for_each_other(secret): + record = make_record() + record_proof = proof.make_proof(secret, record) + confirmation = proof.make_confirmation(secret, record) + auto = proof.make_auto_admit(secret, "shop") + assert len({record_proof, confirmation, auto}) == 3 + assert not proof.verify_confirmation(secret, record, record_proof) + assert not proof.verify_proof(secret, record, confirmation) + assert proof.verify_auto_admit(secret, "shop", auto) + assert not proof.verify_auto_admit(secret, "other", auto) + assert not proof.verify_auto_admit(secret, "shop", confirmation) + + +def test_entry_secrets_are_long_and_checked(): + value = proof.new_entry_secret() + assert len(value) >= proof.MIN_SECRET_LENGTH + assert proof.parse_entry_secret(f" {value}\n") == value.encode() + with pytest.raises(ValueError): + proof.parse_entry_secret("short") + with pytest.raises(ValueError): + proof.parse_entry_secret("x" * 42) + with pytest.raises(ValueError): + proof.parse_entry_secret("x" * 40 + " y") + + +def test_proof_shape_is_all_the_service_can_check(): + assert proof.proof_error("a" * 64) is None + assert proof.proof_error("A" * 64) is not None + assert proof.proof_error(1) is not None + + +def test_keys_carry_their_scope_and_hash_without_the_key(): + account = keys.new_key(keys.ACCOUNT) + enroll = keys.new_key(keys.ENROLL) + assert account.startswith("kc1a_") and enroll.startswith("kc1e_") + assert keys.key_scope(account) == keys.ACCOUNT + assert keys.key_scope(enroll) == keys.ENROLL + assert keys.key_scope("kc1x_" + "a" * 43) is None + assert keys.key_scope(None) is None + assert len(keys.key_hash(account)) == 64 + assert account not in keys.key_hash(account) + with pytest.raises(ValueError): + keys.new_key("root") diff --git a/tests/test_node_cli.py b/tests/test_node_cli.py new file mode 100644 index 0000000..62a82b1 --- /dev/null +++ b/tests/test_node_cli.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""keel-cloud-node's command line, and the client's error handling""" + +import io +import json +import urllib.error + +import pytest + +from keel_cloud.node import NodeError +from keel_cloud.node.cli import main +from keel_cloud.node.client import MAX_ANSWER, Client, CloudError, NoRedirect + + +class Recorder: + calls: list = [] + + def __init__(self, spec_path, state_dir): + self.where = (spec_path, state_dir) + + def __getattr__(self, name): + def record(*args): + Recorder.calls.append((name, args, self.where)) + return record + + +@pytest.fixture(autouse=True) +def fresh(): + Recorder.calls = [] + + +@pytest.mark.parametrize("argv,call", [ + (["entry-secret"], ("entry_secret", ())), + (["enroll"], ("enroll", (None,))), + (["enroll", "--endpoint", "[2001:db8::1]:51820"], + ("enroll", ("[2001:db8::1]:51820",))), + (["sync"], ("sync", (0, 0))), + (["sync", "--wait", "500", "--since", "3"], ("sync", (3, 50))), + (["confirm", "KEY", "--account-key-file", "/k"], + ("confirm", ("KEY", "/k"))), + (["auto-admit", "on", "--account-key-file", "/k"], + ("auto_admit", (True, "/k"))), + (["auto-admit", "off", "--account-key-file", "/k"], + ("auto_admit", (False, "/k"))), + (["forget", "KEY"], ("forget", ("KEY",))), + (["status"], ("status", ())), + (["run"], ("run", ())), +]) +def test_each_action_calls_the_agent(argv, call): + assert main(["--spec", "/s", "--state-dir", "/d", *argv], + agent_factory=Recorder) == 0 + assert Recorder.calls == [(*call, ("/s", "/d"))] + + +def test_the_spec_defaults_to_keel_spec(monkeypatch): + monkeypatch.setenv("KEEL_SPEC", "/from/env.yaml") + main(["status"], agent_factory=Recorder) + assert Recorder.calls[0][2][0] == "/from/env.yaml" + + +@pytest.mark.parametrize("error", [NodeError("no spec"), + CloudError(401, "a valid API key")]) +def test_errors_exit_1_with_the_message(capsys, error): + class Failing: + def __init__(self, *args): + pass + + def status(self): + raise error + assert main(["status"], agent_factory=Failing) == 1 + assert str(error) in capsys.readouterr().err + + +class Opener: + def __init__(self, failure): + self.failure = failure + + def open(self, request, timeout): + raise self.failure + + +def http_error(body: bytes) -> urllib.error.HTTPError: + return urllib.error.HTTPError("https://x", 409, "Conflict", {}, + io.BytesIO(body)) + + +def test_the_client_reads_the_services_error_message(): + client = Client("https://x", "k", opener=Opener(http_error( + b'{"error": "ts: not newer"}'))) + with pytest.raises(CloudError, match="ts: not newer") as refused: + client.register("shop", {}, "p") + assert refused.value.status == 409 + + +def test_the_client_survives_an_error_without_json(): + client = Client("https://x", "k", opener=Opener(http_error(b""))) + with pytest.raises(CloudError, match="HTTP 409"): + client.confirm("shop", "key", "c" * 64) + + +class Answer(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class Recording: + def __init__(self, body: bytes): + self.body = body + self.requests = [] + + def open(self, request, timeout): + self.requests.append(request) + return Answer(self.body) + + +def test_the_client_refuses_an_oversized_answer(): + client = Client("https://x", "k", + opener=Recording(b"x" * (MAX_ANSWER + 1))) + with pytest.raises(CloudError, match="more than"): + client.peers("shop") + + +def test_auto_admit_sends_its_proof_or_off(): + opener = Recording(b"{}") + client = Client("https://x", "k", opener=opener) + client.auto_admit("shop", "d" * 64) + client.auto_admit("shop", None) + bodies = [json.loads(r.data) for r in opener.requests] + assert bodies == [{"auto_admit": True, "proof": "d" * 64}, + {"auto_admit": False}] + assert {r.get_method() for r in opener.requests} == {"PATCH"} + + +def test_the_client_never_follows_a_redirect(): + handler = NoRedirect() + with pytest.raises(CloudError, match="redirected") as refused: + handler.redirect_request(None, None, 302, "Found", {}, + "http://elsewhere/") + assert refused.value.status == 302 + client = Client("https://x", "k") + assert any(isinstance(h, NoRedirect) for h in client.opener.handlers) diff --git a/tests/test_node_spec.py b/tests/test_node_spec.py new file mode 100644 index 0000000..f7b0cc8 --- /dev/null +++ b/tests/test_node_spec.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The agent's reading and writing of the spec""" + +import ipaddress +import os + +import pytest +import yaml + +from conftest import wg_key +from keel_cloud.node import NodeError, spec +from nodes import spec_doc + + +def doc(**cloud_changes) -> dict: + made = spec_doc("fd00:6b65:c1::1/64", "https://cloud.example.org:8443") + made["cloud"]["api_key"]["file"] = "/etc/keel/secrets/cloud_api_key" + made["cloud"]["entry_secret"]["file"] = "/etc/keel/secrets/entry" + made["cloud"].update(cloud_changes) + return made + + +def test_cloud_settings_from_a_complete_section(): + settings = spec.cloud_settings(doc(ca_file="/etc/keel/ca.pem", + endpoint="https://c.example.org/")) + assert settings == spec.CloudSettings( + "https://c.example.org", "/etc/keel/secrets/cloud_api_key", + "/etc/keel/secrets/entry", "shop", "/etc/keel/ca.pem") + + +@pytest.mark.parametrize("changes,fragment", [ + ({"endpoint": "http://cloud.example.org"}, "TLS only"), + ({"endpoint": "https://cloud.example.org/v1"}, "no path"), + ({"endpoint": None}, "https://"), + ({"set": "Shop"}, "cloud.set"), + ({"api_key": "skip"}, "standalone"), + ({"api_key": {"file": "relative"}}, "absolute path"), + ({"entry_secret": {"generate": True}}, "secret reference"), + ({"ca_file": "ca.pem"}, "ca_file"), + ({"colour": "blue"}, "cloud.colour: unknown key"), +]) +def test_cloud_settings_errors(changes, fragment): + with pytest.raises(NodeError, match=fragment): + spec.cloud_settings(doc(**changes)) + + +def test_no_cloud_section_is_standalone(): + with pytest.raises(NodeError, match="standalone"): + spec.cloud_settings({"version": 1}) + + +def test_load_reports_missing_bad_and_non_mapping_files(tmp_path): + with pytest.raises(NodeError, match="No such file"): + spec.load(str(tmp_path / "none.yaml")) + path = tmp_path / "spec.yaml" + path.write_text("a: [") + with pytest.raises(NodeError, match="not YAML"): + spec.load(str(path)) + path.write_text("- 1\n") + with pytest.raises(NodeError, match="not a Keel spec"): + spec.load(str(path)) + + +def secret_file(tmp_path, text="value", mode=0o600): + path = tmp_path / "secret" + path.write_text(text) + os.chmod(path, mode) + return str(path) + + +def test_a_secret_file_must_be_the_owners_and_0600(tmp_path): + owner = os.getuid() + assert spec.read_secret(secret_file(tmp_path, " v \n"), owner) == "v" + with pytest.raises(NodeError, match="mode 0600"): + spec.read_secret(secret_file(tmp_path, mode=0o640), owner) + with pytest.raises(NodeError, match="root's"): + spec.read_secret(secret_file(tmp_path), owner + 1) + with pytest.raises(NodeError, match="empty"): + spec.read_secret(secret_file(tmp_path, "\n"), owner) + with pytest.raises(NodeError, match="not a regular file"): + spec.read_secret(str(tmp_path), owner) + with pytest.raises(NodeError, match="No such file"): + spec.read_secret(str(tmp_path / "none"), owner) + (tmp_path / "secret").write_bytes(b"\xff") + with pytest.raises(NodeError, match="not ASCII"): + spec.read_secret(str(tmp_path / "secret"), owner) + + +def test_overlay_reads_this_nodes_side(): + made = doc() + made["network"]["overlay"]["wireguard"]["ipv4_address"] = "10.9.0.1/24" + overlay = spec.overlay(made) + assert overlay == spec.Overlay( + "wg0", ["fd00:6b65:c1::1", "10.9.0.1"], 51820, + (ipaddress.ip_network("fd00:6b65:c1::/64"), + ipaddress.ip_network("10.9.0.0/24"))) + + +def test_a_secret_behind_a_symbolic_link_is_refused(tmp_path): + target = secret_file(tmp_path) + os.symlink(target, tmp_path / "link") + with pytest.raises(NodeError): + spec.read_secret(str(tmp_path / "link"), os.getuid()) + + +def test_peer_keys_and_without_peer(): + one, two = wg_key(), wg_key() + made = doc() + made["network"]["overlay"]["wireguard"]["peers"] = [ + {"public_key": one}, {"public_key": two}, "odd"] + assert spec.peer_keys(made) == {one, two} + assert spec.peers_of(spec.without_peer(made, one)) == [ + {"public_key": two}, "odd"] + assert len(spec.peers_of(made)) == 3 + + +def test_write_replaces_a_stale_temporary_file(tmp_path): + path = tmp_path / "instance.yaml" + path.write_text("version: 1\n") + (tmp_path / ".instance.yaml.cloud").write_text("stale") + spec.write(str(path), {"version": 1}, lambda candidate: "") + assert os.listdir(tmp_path) == ["instance.yaml"] + + +def test_overlay_is_required_and_checked(): + with pytest.raises(NodeError, match="declares the overlay first"): + spec.overlay({"network": {}}) + with pytest.raises(NodeError, match="declares the overlay first"): + spec.overlay({"network": "eth0"}) + made = doc() + made["network"]["overlay"]["wireguard"]["address"] = "nonsense" + with pytest.raises(NodeError, match="address"): + spec.overlay(made) + + +def test_with_peers_keeps_the_operators_peers_and_their_order(): + own, cloud, new = wg_key(), wg_key(), wg_key() + made = doc() + made["network"]["overlay"]["wireguard"]["peers"] = [ + {"public_key": cloud, "allowed_ips": ["fd00::2/128"]}, + {"public_key": own, "allowed_ips": ["fd00::9/128"]}, + "not a mapping", + ] + managed = {cloud: {"public_key": cloud, "endpoint": "[2001:db8::2]:1", + "allowed_ips": ["fd00::2/128"]}, + new: {"public_key": new, "allowed_ips": ["fd00::3/128"]}} + result = spec.with_peers(made, managed) + peers = spec.peers_of(result) + assert peers[0]["endpoint"] == "[2001:db8::2]:1" + assert peers[1]["public_key"] == own and peers[2] == "not a mapping" + assert peers[3]["public_key"] == new + assert spec.peers_of(made)[0].get("endpoint") is None + + +def test_write_validates_before_it_replaces(tmp_path): + path = tmp_path / "instance.yaml" + path.write_text("version: 1\n") + os.chmod(path, 0o600) + seen = [] + + def validate(candidate): + seen.append(yaml.safe_load(open(candidate))) + return "" + spec.write(str(path), {"version": 1, "x": [1]}, validate) + assert yaml.safe_load(path.read_text()) == {"version": 1, "x": [1]} + assert seen == [{"version": 1, "x": [1]}] + assert oct(os.stat(path).st_mode & 0o777) == "0o600" + assert os.listdir(tmp_path) == ["instance.yaml"] + + +def test_write_keeps_the_old_spec_when_keel_refuses(tmp_path): + path = tmp_path / "instance.yaml" + path.write_text("version: 1\n") + with pytest.raises(NodeError, match="keel refuses"): + spec.write(str(path), {"version": 2}, lambda candidate: "bad") + assert path.read_text() == "version: 1\n" + assert os.listdir(tmp_path) == ["instance.yaml"] diff --git a/tests/test_pins.py b/tests/test_pins.py new file mode 100644 index 0000000..22f5002 --- /dev/null +++ b/tests/test_pins.py @@ -0,0 +1,236 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The trust model on the node: proofs, confirmation, pinning""" + +import ipaddress +import json +import os + +import pytest + +from conftest import NOW, make_record, wg_key +from keel_cloud.node import NodeError, pins +from keel_cloud.proof import ( + make_auto_admit, + make_confirmation, + make_proof, + new_entry_secret, + parse_entry_secret, +) + +OWN = pins.Own("own", ("fd00:6b65:c1::1", "10.9.0.1"), + (ipaddress.ip_network("fd00:6b65:c1::/64"), + ipaddress.ip_network("10.9.0.0/24"))) + + +def node(secret, confirmed=True, **changes): + record = make_record(**{"overlay": ["fd00:6b65:c1::2"], **changes}) + return {"record": record, "proof": make_proof(secret, record), + "status": "confirmed" if confirmed else "pending", + "confirmation": make_confirmation(secret, record) + if confirmed else None} + + +def rules(secret, **changes): + return pins.Rules(**{"secret": secret, "set_name": "shop", "now": NOW, + **changes}) + + +def decide(secret, existing, *nodes, **changes): + return pins.decide(existing, list(nodes), OWN, rules(secret, **changes)) + + +def key_of(item) -> str: + return item["record"]["public_key"] + + +def test_a_confirmed_node_with_a_valid_proof_is_admitted_and_pinned(secret): + peer = node(secret) + decision = decide(secret, {}, peer) + assert decision.pins[key_of(peer)]["overlay"] == ["fd00:6b65:c1::2"] + assert "admitted and pinned" in decision.notes[0] + assert pins.wireguard_peers(decision.pins)[key_of(peer)] == { + "public_key": key_of(peer), "endpoint": "[2001:db8::10]:51820", + "allowed_ips": ["fd00:6b65:c1::2/128"], "persistent_keepalive": 25} + + +def test_a_peer_without_endpoint_waits_to_be_reached(secret): + decision = decide(secret, {}, node(secret, endpoint=None)) + peer = next(iter(pins.wireguard_peers(decision.pins).values())) + assert "endpoint" not in peer + + +def test_a_pending_node_is_held_for_the_operator(secret): + peer = node(secret, confirmed=False) + decision = decide(secret, {}, peer) + assert decision.pins == {} and decision.held == [key_of(peer)] + assert "waiting for the operator's confirmation" in decision.notes[0] + + +def test_the_service_cannot_confirm_by_itself(secret): + """A confirmed status, or a confirmation made without the secret, + admits nothing""" + peer = node(secret, confirmed=False) + peer["status"] = "confirmed" + peer["confirmation"] = "c" * 64 + assert decide(secret, {}, peer).pins == {} + other = parse_entry_secret(new_entry_secret()) + peer["confirmation"] = make_confirmation(other, peer["record"]) + assert decide(secret, {}, peer).pins == {} + + +def test_a_confirmation_is_for_one_key_and_its_addresses(secret): + first, second = node(secret), node(secret, overlay=["fd00:6b65:c1::3"]) + second["confirmation"] = first["confirmation"] + assert key_of(second) not in decide(secret, {}, second).pins + + +def test_automatic_admission_needs_its_proof(secret): + peer = node(secret, confirmed=False) + assert decide(secret, {}, peer, auto_admit=True).pins + listing = {"auto_admit": True, "auto_admit_proof": "d" * 64} + assert not pins.auto_admit(listing, secret, "shop") + listing["auto_admit_proof"] = make_auto_admit(secret, "shop") + assert pins.auto_admit(listing, secret, "shop") + assert not pins.auto_admit({**listing, "auto_admit": False}, secret, + "shop") + + +def test_a_record_the_cloud_made_up_does_not_verify(secret): + """A compromised cloud can propose, and the node refuses it""" + other = parse_entry_secret(new_entry_secret()) + decision = decide(secret, {}, node(other)) + assert decision.pins == {} + assert "does not verify" in decision.notes[0] + + +def test_a_record_the_cloud_changed_does_not_verify(secret): + peer = node(secret) + peer["record"]["overlay"] = ["fd00:6b65:c1::99"] + assert decide(secret, {}, peer).pins == {} + + +def test_invalid_records_and_other_sets_are_refused(secret): + decision = decide(secret, {}, {"record": {"set": "shop"}}, + node(secret, set="other"), "not a mapping") + assert decision.pins == {} + assert "a record was refused" in decision.notes[0] + assert "names set other" in decision.notes[1] + assert "not a mapping" in decision.notes[2] + + +def test_a_record_that_breaks_the_rules_does_not_stop_the_others( + secret, monkeypatch): + def broken(*args): + raise ValueError("odd") + monkeypatch.setattr(pins, "validate_record", broken) + decision = decide(secret, {}, node(secret)) + assert decision.notes == ["a record was refused: odd"] + + +def test_this_nodes_own_record_is_skipped(secret): + peer = node(secret, public_key="own") + peer["record"]["public_key"] = "own" + decision = decide(secret, {}, peer) + assert decision.pins == {} and decision.notes == [] + + +def test_a_record_dated_ahead_is_refused(secret): + decision = decide(secret, {}, node(secret, ts=NOW + 301)) + assert decision.pins == {} and "dated ahead" in decision.notes[0] + + +def test_a_pinned_peer_takes_a_newer_endpoint_only(secret): + first = node(secret) + key = key_of(first) + pinned = decide(secret, {}, first).pins + moved = node(secret, confirmed=False, public_key=key, ts=NOW + 10, + endpoint="[2001:db8::20]:51820") + decision = decide(secret, pinned, moved) + assert decision.pins[key]["endpoint"] == "[2001:db8::20]:51820" + older = node(secret, public_key=key, ts=NOW - 10, + endpoint="[2001:db8::30]:51820") + assert decide(secret, decision.pins, older).pins == decision.pins + + +def test_a_pinned_peer_keeps_its_addresses(secret): + first = node(secret) + pinned = decide(secret, {}, first).pins + changed = node(secret, public_key=key_of(first), + overlay=["fd00:6b65:c1::7"], ts=NOW + 1) + decision = decide(secret, pinned, changed) + assert decision.pins == pinned + assert "keeps its addresses" in decision.notes[0] + + +def test_a_new_key_cannot_take_an_admitted_address(secret): + pinned = decide(secret, {}, node(secret)).pins + impostor = node(secret) + decision = decide(secret, pinned, impostor) + assert key_of(impostor) not in decision.pins + assert "already this node's or a pinned peer's" in decision.notes[0] + own = node(secret, overlay=["fd00:6b65:c1::1"]) + assert decide(secret, {}, own).pins == {} + + +@pytest.mark.parametrize("overlay", [["fd00:6b65:c2::2"], + ["fd00:6b65:c1::2", "10.9.1.2"]]) +def test_addresses_outside_the_overlay_prefixes_are_refused(secret, + overlay): + """A peer cannot claim a route to anything but the set's overlay""" + decision = decide(secret, {}, node(secret, overlay=overlay)) + assert decision.pins == {} + assert "outside this node's overlay prefixes" in decision.notes[0] + + +def test_keys_declared_by_hand_or_forgotten_are_left_alone(secret): + peer = node(secret) + for field in ("declared", "forgotten"): + decision = decide(secret, {}, peer, + **{field: frozenset({key_of(peer)})}) + assert decision.pins == {} and decision.notes == [] + + +def test_a_peer_missing_from_the_cloud_stays_pinned(secret): + pinned = decide(secret, {}, node(secret)).pins + assert decide(secret, pinned).pins == pinned + + +def test_decide_does_not_change_the_pins_it_is_given(secret): + existing = {} + decide(secret, existing, node(secret)) + assert existing == {} + + +def test_pin_state_round_trips_per_set_with_mode_0600(tmp_path): + path = str(tmp_path / "pins.json") + assert pins.load(path, "shop") == pins.State({}, ()) + pins.save(path, "shop", pins.State({"k": {"overlay": ["fd00::2"]}}, + ("gone", "gone"))) + pins.save(path, "web", pins.State({})) + assert pins.load(path, "shop") == pins.State( + {"k": {"overlay": ["fd00::2"]}}, ("gone",)) + assert oct(os.stat(path).st_mode & 0o777) == "0o600" + assert json.load(open(path))["version"] == 1 + os.symlink("/nonexistent", path + ".new") + pins.save(path, "shop", pins.State({})) + assert not os.path.islink(path) + + +@pytest.mark.parametrize("text", ["{", '{"version": 9}', "[]", + '{"version": 1}']) +def test_a_damaged_pin_state_stops_the_agent(tmp_path, text): + path = tmp_path / "pins.json" + path.write_text(text) + with pytest.raises(NodeError): + pins.load(str(path), "shop") + with pytest.raises(NodeError): + pins.save(str(path), "shop", pins.State({})) + + +def test_a_node_with_two_families_gets_both_host_routes(secret): + peer = node(secret, overlay=["fd00:6b65:c1::5", "10.9.0.5"]) + decision = decide(secret, {}, peer) + routes = pins.wireguard_peers(decision.pins)[key_of(peer)][ + "allowed_ips"] + assert routes == ["fd00:6b65:c1::5/128", "10.9.0.5/32"] + assert wg_key() not in decision.pins diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..81229d7 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,204 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The service around the API: limits, configuration, TLS, command line""" + +import io +import json +import ssl + +import pytest + +from conftest import make_record +from keel_cloud.api import admin, server +from keel_cloud.api.config import ConfigError, load +from keel_cloud.api.limits import FailureLimiter +from keel_cloud.api.store import Store + + +class Clock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def test_the_limiter_forgets_failures_after_its_window(): + clock = Clock() + limiter = FailureLimiter(limit=2, window=60, clock=clock) + assert not limiter.blocked("a") + limiter.failed("a") + limiter.failed("a") + assert limiter.blocked("a") and not limiter.blocked("b") + clock.now = 61 + assert not limiter.blocked("a") + assert limiter.failures == {} + + +def test_the_limiter_keeps_a_bounded_table(): + clock = Clock() + limiter = FailureLimiter(limit=5, window=60, clock=clock, max_clients=2) + limiter.failed("a") + limiter.failed("b") + limiter.failed("c") + assert len(limiter.failures) == 2 and "a" not in limiter.failures + clock.now = 100 + limiter.failed("d") + assert set(limiter.failures) == {"d"} + + +def test_config_defaults_without_a_file(tmp_path): + config = load(str(tmp_path / "missing.conf")) + assert config.listen == "::" and config.port == 8443 + assert config.database == "/var/lib/keel-cloud/cloud.db" + + +def test_config_reads_the_api_section(tmp_path): + path = tmp_path / "api.conf" + path.write_text("[api]\nport = 9443\nlisten = ::1\npoll_interval = 0.5\n") + config = load(str(path)) + assert (config.port, config.listen, config.poll_interval) == (9443, + "::1", 0.5) + + +@pytest.mark.parametrize("text,fragment", [ + ("[api]\nport = 0\n", "port"), + ("[api]\nlisten = everywhere\n", "listen"), + ("[api]\npoll_interval = soon\n", "poll_interval"), + ("[api]\ndatabase = relative.db\n", "absolute"), + ("[api]\ncolour = blue\n", "unknown key colour"), + ("not ini", "File contains no section"), +]) +def test_config_errors_name_the_key(tmp_path, text, fragment): + path = tmp_path / "api.conf" + path.write_text(text) + with pytest.raises(ConfigError, match=fragment): + load(str(path)) + + +def write_config(tmp_path, certificate, **extra) -> str: + cert, key = certificate + lines = ["[api]", f"certificate = {cert}", f"private_key = {key}", + f"database = {tmp_path / 'cloud.db'}"] + lines += [f"{k} = {v}" for k, v in extra.items()] + path = tmp_path / "api.conf" + path.write_text("\n".join(lines) + "\n") + return str(path) + + +def test_the_server_runs_tls_only_on_the_configured_address( + tmp_path, certificate): + seen = {} + + def run(app, **kwargs): + seen.update(kwargs) + assert server.main(["--config", write_config(tmp_path, certificate)], + run=run) == 0 + assert seen["host"] == "::" and seen["port"] == 8443 + assert isinstance(seen["ssl_context"], ssl.SSLContext) + assert seen["ssl_context"].minimum_version == ssl.TLSVersion.TLSv1_2 + + +def test_the_server_refuses_to_start_without_a_certificate(tmp_path, + capsys): + path = tmp_path / "api.conf" + path.write_text(f"[api]\ncertificate = {tmp_path}/none.pem\n") + assert server.main(["--config", str(path)], run=None) == 1 + assert "keel-cloud-api:" in capsys.readouterr().err + + +def cli(tmp_path, *argv) -> tuple[int, str]: + out = io.StringIO() + code = admin.main(["--database", str(tmp_path / "cloud.db"), *argv], + out=out) + return code, out.getvalue() + + +def test_the_command_line_makes_accounts_and_keys_once(tmp_path): + code, account_key = cli(tmp_path, "account", "create", "acme") + assert code == 0 and account_key.startswith("kc1a_") + code, enroll_key = cli(tmp_path, "key", "create", "acme") + assert enroll_key.startswith("kc1e_") + code, listed = cli(tmp_path, "--json", "key", "list", "acme") + rows = json.loads(listed) + assert [r["scope"] for r in rows] == ["account", "enroll"] + assert account_key.strip() not in listed + assert cli(tmp_path, "key", "revoke", "acme", "2")[0] == 0 + code, text = cli(tmp_path, "key", "list", "acme") + assert "revoked None" not in text.splitlines()[1] + + +def test_the_command_line_manages_sets_and_peers(tmp_path, capsys): + cli(tmp_path, "account", "create", "acme") + store = Store(str(tmp_path / "cloud.db")) + record = make_record() + store.register(store.account_id("acme"), record, "a" * 64) + store.close() + code, text = cli(tmp_path, "peer", "list", "acme", "shop") + assert record["public_key"] in text and "pending" in text + store = Store(str(tmp_path / "cloud.db")) + store.set_auto_admit(store.account_id("acme"), "shop", "d" * 64) + store.close() + assert "auto_admit True" in cli(tmp_path, "set", "list", "acme")[1] + assert cli(tmp_path, "set", "auto-admit", "acme", "shop", "off")[0] == 0 + assert "auto_admit False" in cli(tmp_path, "set", "list", "acme")[1] + assert cli(tmp_path, "peer", "remove", "acme", "shop", + record["public_key"])[0] == 0 + assert cli(tmp_path, "peer", "remove", "acme", "shop", + record["public_key"])[0] == 1 + assert "no such node" in capsys.readouterr().err + + +@pytest.mark.parametrize("argv", [ + ("peer", "confirm", "acme", "shop", "KEY"), + ("set", "auto-admit", "acme", "shop", "on")]) +def test_the_instance_cannot_confirm_or_turn_admission_on(tmp_path, argv): + """Both need the entry secret, which only the nodes hold""" + with pytest.raises(SystemExit): + cli(tmp_path, *argv) + + +def test_the_command_line_reports_what_it_cannot_open(tmp_path, capsys): + code = admin.main(["--database", str(tmp_path / "no" / "dir.db"), + "set", "list", "acme"]) + assert code == 1 + code = admin.main(["--config", str(tmp_path / "missing.conf"), + "--database", str(tmp_path / "x.db"), + "set", "list", "nobody"]) + assert code == 1 + assert "no such account" in capsys.readouterr().err + + +class FakeOs: + def __init__(self, euid): + self.euid = euid + self.calls = [] + + def geteuid(self): + return self.euid + + def __getattr__(self, name): + return lambda *args: self.calls.append((name, args)) + + +def test_root_becomes_the_service_user_before_it_opens_the_database(): + fake = FakeOs(0) + admin.drop_privileges("root", fake) + assert [name for name, _ in fake.calls] == ["setgroups", "setgid", + "setuid", "umask"] + fake = FakeOs(0) + admin.drop_privileges("no-such-user-here", fake) + assert fake.calls == [] + fake = FakeOs(1000) + admin.drop_privileges("root", fake) + assert fake.calls == [] + + +def test_the_command_line_reads_the_database_path_from_the_config( + tmp_path, capsys): + path = tmp_path / "api.conf" + path.write_text("[api]\nport = nope\n") + assert admin.main(["--config", str(path), "set", "list", "a"]) == 1 + assert "port" in capsys.readouterr().err + path.write_text(f"[api]\ndatabase = {tmp_path}/c.db\n") + assert admin.main(["--config", str(path), "account", "create", "a"], + out=io.StringIO()) == 0 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..a776ffa --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,174 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The SQLite state: hashes only, sets, pinning, revisions""" + +import sqlite3 + +import pytest + +from conftest import NOW, make_record +from keel_cloud.api import store as store_module +from keel_cloud.api.store import CONFIRMED, PENDING, Store, StoreError +from keel_cloud.keys import ACCOUNT, ENROLL + + +@pytest.fixture +def store(): + made = Store(":memory:", clock=lambda: NOW) + yield made + made.close() + + +@pytest.fixture +def account(store): + key = store.create_account("acme") + return store.account_id("acme"), key + + +def test_an_account_key_authenticates_and_only_its_hash_is_stored( + tmp_path): + path = tmp_path / "cloud.db" + store = Store(str(path), clock=lambda: NOW) + key = store.create_account("acme") + found = store.authenticate(key) + assert found.scope == ACCOUNT and found.account_id == 1 + store.close() + assert key.encode() not in path.read_bytes() + assert key[5:].encode() not in path.read_bytes() + + +def test_account_names_are_labels_and_unique(store): + store.create_account("acme") + with pytest.raises(StoreError, match="exists"): + store.create_account("acme") + with pytest.raises(StoreError) as refused: + store.create_account("Not A Label") + assert refused.value.kind == "invalid" + with pytest.raises(StoreError, match="no such account"): + store.account_id("nobody") + + +def test_keys_are_scoped_listed_without_values_and_revoked(store, account): + account_id, _ = account + enroll = store.create_key(account_id, ENROLL, "node a") + assert store.authenticate(enroll).scope == ENROLL + listed = store.list_keys(account_id) + assert [k["scope"] for k in listed] == [ACCOUNT, ENROLL] + assert all("hash" not in k and "key" not in k for k in listed) + store.revoke_key(account_id, listed[1]["id"]) + assert store.authenticate(enroll) is None + with pytest.raises(StoreError, match="no such live key"): + store.revoke_key(account_id, listed[1]["id"]) + + +def test_key_creation_checks_scope_and_label(store, account): + account_id, _ = account + with pytest.raises(StoreError, match="scope"): + store.create_key(account_id, "root") + with pytest.raises(StoreError, match="label"): + store.create_key(account_id, ENROLL, "x" * 65) + + +def test_unknown_and_malformed_keys_do_not_authenticate(store, account): + assert store.authenticate("kc1e_" + "a" * 43) is None + assert store.authenticate("garbage") is None + + +def test_a_key_whose_prefix_was_changed_does_not_authenticate(store, + account): + _, key = account + assert store.authenticate("kc1e_" + key[5:]) is None + + +def test_registration_creates_the_set_pending_and_bumps_revision( + store, account): + account_id, _ = account + record = make_record() + result = store.register(account_id, record, "a" * 64) + assert result == {"status": PENDING, "created": True, "revision": 1} + view = store.view(account_id, "shop") + assert view.revision == 1 and not view.auto_admit + assert view.nodes[0]["record"] == record + assert store.list_sets(account_id) == [ + {"name": "shop", "auto_admit": False, "revision": 1}] + + +def test_an_update_needs_a_newer_ts_and_the_same_addresses(store, account): + account_id, _ = account + record = make_record() + store.register(account_id, record, "a" * 64) + with pytest.raises(StoreError, match="not newer"): + store.register(account_id, record, "b" * 64) + moved = {**record, "overlay": ["fd00:6b65:c1::9"], "ts": NOW + 1} + with pytest.raises(StoreError, match="keeps its addresses"): + store.register(account_id, moved, "b" * 64) + newer = {**record, "endpoint": "[2001:db8::11]:51820", "ts": NOW + 1} + result = store.register(account_id, newer, "b" * 64) + assert result == {"status": PENDING, "created": False, "revision": 2} + assert store.view(account_id, "shop").nodes[0]["proof"] == "b" * 64 + + +def test_two_nodes_of_a_set_never_share_an_address(store, account): + account_id, _ = account + store.register(account_id, make_record(), "a" * 64) + with pytest.raises(StoreError, match="belongs to another node"): + store.register(account_id, make_record(), "a" * 64) + + +def test_confirm_admits_and_remove_forgets(store, account): + account_id, _ = account + record = make_record() + store.register(account_id, record, "a" * 64) + assert store.view(account_id, "shop").nodes[0]["confirmation"] is None + assert store.confirm(account_id, "shop", record["public_key"], + "c" * 64) == 2 + node = store.view(account_id, "shop").nodes[0] + assert (node["status"], node["confirmation"]) == (CONFIRMED, "c" * 64) + assert store.remove(account_id, "shop", record["public_key"]) == 3 + assert store.view(account_id, "shop").nodes == () + with pytest.raises(StoreError, match="no such node"): + store.confirm(account_id, "shop", record["public_key"], "c" * 64) + with pytest.raises(StoreError, match="no such node"): + store.remove(account_id, "shop", record["public_key"]) + + +def test_auto_admit_keeps_its_proof_and_unknown_sets(store, account): + account_id, _ = account + with pytest.raises(StoreError, match="no such set"): + store.revision(account_id, "shop") + store.register(account_id, make_record(), "a" * 64) + assert store.set_auto_admit(account_id, "shop", "d" * 64) == 2 + assert store.view(account_id, "shop").auto_admit == "d" * 64 + assert store.list_sets(account_id)[0]["auto_admit"] is True + store.set_auto_admit(account_id, "shop", None) + assert store.view(account_id, "shop").auto_admit is None + + +def test_sets_belong_to_one_account(store, account): + account_id, _ = account + store.register(account_id, make_record(), "a" * 64) + store.create_account("other") + with pytest.raises(StoreError, match="no such set"): + store.view(store.account_id("other"), "shop") + + +def test_limits_on_sets_and_nodes(store, account, monkeypatch): + account_id, _ = account + monkeypatch.setattr(store_module, "MAX_SETS_PER_ACCOUNT", 1) + monkeypatch.setattr(store_module, "MAX_NODES_PER_SET", 1) + store.register(account_id, make_record(), "a" * 64) + with pytest.raises(StoreError, match="nodes per set"): + store.register(account_id, make_record(overlay=["fd00::2"]), + "a" * 64) + with pytest.raises(StoreError, match="sets per account"): + store.register(account_id, make_record(set="other"), "a" * 64) + + +def test_a_failed_transaction_leaves_nothing(store, account, monkeypatch): + account_id, _ = account + + def broken(*args): + raise sqlite3.OperationalError("disk") + monkeypatch.setattr(store, "_insert_node", broken) + with pytest.raises(sqlite3.OperationalError): + store.register(account_id, make_record(), "a" * 64) + assert store.list_sets(account_id) == []