From 952d1c2baefd9be575d857746b2f15e6bd95b231 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:01:45 +0000 Subject: [PATCH 1/3] feat: Phase A of Keel Cloud, the API service and the node agent The service (keel_cloud.api): an aiohttp API, TLS only, over SQLite that stores accounts, the SHA-256 of each API key, sets, and per node its public record and its entry secret proof. Two key scopes, an account key and a narrower enrollment key. Nodes register with a proof the service relays but cannot make; new nodes stay pending until the operator confirms them with the account key; a set's revision drives a long poll. A registered key keeps its overlay addresses and two nodes of a set never share one. Failed keys are limited per client. The node agent (keel_cloud.node, keel-cloud-node, also `keel cloud`): reads the spec's cloud section and its 0600 secret files, registers this node, checks every record of its set against the entry secret, admits only confirmed nodes whose proof verifies, pins them per set, and writes them into network.overlay.wireguard.peers after keel spec validate accepts the new spec. It never writes WireGuard's configuration; keel spec apply converges the spec under 0018's window. 166 tests, 99.7 percent of lines and branches, the agent against the real service over TLS on [::1]. --- .gitignore | 18 ++ keel_cloud/__init__.py | 14 ++ keel_cloud/api/__init__.py | 2 + keel_cloud/api/admin.py | 163 ++++++++++++++++++ keel_cloud/api/app.py | 222 ++++++++++++++++++++++++ keel_cloud/api/config.py | 81 +++++++++ keel_cloud/api/limits.py | 44 +++++ keel_cloud/api/server.py | 41 +++++ keel_cloud/api/store.py | 335 ++++++++++++++++++++++++++++++++++++ keel_cloud/keys.py | 44 +++++ keel_cloud/node/__init__.py | 13 ++ keel_cloud/node/agent.py | 266 ++++++++++++++++++++++++++++ keel_cloud/node/cli.py | 82 +++++++++ keel_cloud/node/client.py | 68 ++++++++ keel_cloud/node/pins.py | 156 +++++++++++++++++ keel_cloud/node/spec.py | 195 +++++++++++++++++++++ keel_cloud/proof.py | 69 ++++++++ keel_cloud/record.py | 140 +++++++++++++++ pyproject.toml | 42 +++++ tests/conftest.py | 55 ++++++ tests/nodes.py | 114 ++++++++++++ tests/test_agent.py | 290 +++++++++++++++++++++++++++++++ tests/test_app.py | 247 ++++++++++++++++++++++++++ tests/test_formats.py | 137 +++++++++++++++ tests/test_node_cli.py | 92 ++++++++++ tests/test_node_spec.py | 148 ++++++++++++++++ tests/test_pins.py | 160 +++++++++++++++++ tests/test_service.py | 194 +++++++++++++++++++++ tests/test_store.py | 167 ++++++++++++++++++ 29 files changed, 3599 insertions(+) create mode 100644 .gitignore create mode 100644 keel_cloud/__init__.py create mode 100644 keel_cloud/api/__init__.py create mode 100644 keel_cloud/api/admin.py create mode 100644 keel_cloud/api/app.py create mode 100644 keel_cloud/api/config.py create mode 100644 keel_cloud/api/limits.py create mode 100644 keel_cloud/api/server.py create mode 100644 keel_cloud/api/store.py create mode 100644 keel_cloud/keys.py create mode 100644 keel_cloud/node/__init__.py create mode 100644 keel_cloud/node/agent.py create mode 100644 keel_cloud/node/cli.py create mode 100644 keel_cloud/node/client.py create mode 100644 keel_cloud/node/pins.py create mode 100644 keel_cloud/node/spec.py create mode 100644 keel_cloud/proof.py create mode 100644 keel_cloud/record.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/nodes.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_app.py create mode 100644 tests/test_formats.py create mode 100644 tests/test_node_cli.py create mode 100644 tests/test_node_spec.py create mode 100644 tests/test_pins.py create mode 100644 tests/test_service.py create mode 100644 tests/test_store.py 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/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..7ca48c2 --- /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="admit new peers without the" + " operator's confirmation, or stop doing so") + admit.add_argument("account") + admit.add_argument("set") + admit.add_argument("value", choices=("on", "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") + for verb, text in (("confirm", "admit a pending node"), + ("remove", "forget a node; nodes that admitted it" + " keep it until their operator removes it")): + action = peer.add_parser(verb, help=text) + action.add_argument("account") + action.add_argument("set") + action.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, args.value == "on") + elif command == ("peer", "list"): + show(args, peer_rows(store, account, args.set), out, + "{public_key} {status} {overlay} {endpoint} {appliance}" + " {role} {site}") + elif command == ("peer", "confirm"): + store.confirm(account, args.set, args.public_key) + 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) + + +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..19fa877 --- /dev/null +++ b/keel_cloud/api/app.py @@ -0,0 +1,222 @@ +# 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 + 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 + +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.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) + value = data.get("auto_admit") + if set(data) != {"auto_admit"} or not isinstance(value, bool): + raise ApiError(400, 'the body is {"auto_admit": true or false}') + revision = request.app[STORE].set_auto_admit(who.account_id, name, value) + return web.json_response({"set": name, "auto_admit": value, + "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: + nodes = [{"record": node["record"], "proof": node["proof"], + "status": node["status"], + "admitted": node["status"] == CONFIRMED or view.auto_admit} + for node in view.nodes] + return {"set": view.name, "revision": view.revision, + "auto_admit": 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""" + who = principal(request, ACCOUNT) + name = set_name(request) + data = await json_body(request) + key = data.get("public_key") + if set(data) != {"public_key"} or public_key_error(key): + raise ApiError(400, 'the body is {"public_key": KEY}, a WireGuard' + " public key") + revision = request.app[STORE].confirm(who.account_id, name, key) + 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..e634b0d --- /dev/null +++ b/keel_cloud/api/store.py @@ -0,0 +1,335 @@ +# 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, and per node its public record and the proof that came with it. +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 INTEGER NOT NULL DEFAULT 0, + 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')), + 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: bool + 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": bool(r["auto_admit"]), + "revision": r["revision"]} for r in rows] + + def set_auto_admit(self, account_id: int, name: str, value: bool) -> int: + with self._transaction(): + row = self._require_set(account_id, name) + self.db.execute("UPDATE sets SET auto_admit = ? WHERE id = ?", + (int(bool(value)), 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, 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"], "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"], bool(row["auto_admit"]), + tuple(self._nodes(row["id"]))) + + def confirm(self, account_id: int, name: str, public_key: str) -> int: + """The operator admits a pending node into its set""" + with self._transaction(): + row = self._require_set(account_id, name) + cursor = self.db.execute( + "UPDATE nodes SET status = ?, updated = ? WHERE set_id = ?" + " AND public_key = ?", + (CONFIRMED, 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..d6961a7 --- /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.match(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..9d77d35 --- /dev/null +++ b/keel_cloud/node/agent.py @@ -0,0 +1,266 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""What the node agent does: enroll, sync, run, confirm, entry-secret + +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_proof, new_entry_secret, parse_entry_secret +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 has new peers: 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 link.get("ifname") == overlay.interface: + continue + for info in link.get("addr_info", []): + if "local" not in info or any(info.get(flag) for flag in + SKIPPED_FLAGS): + continue + address = ipaddress.ip_address(info["local"]) + 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 _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 = ctx.client.peers(ctx.settings.set, since, wait) + own = public_key(self.system, self.spec_path) + path = self.pins_path() + old = pins.load(path, ctx.settings.set) + decision = pins.decide(old, listing.get("nodes", []), own_key=own, + own_overlay=ctx.overlay.addresses, + secret=ctx.secret, set_name=ctx.settings.set) + for note in decision.notes: + self.system.say(note) + if decision.pins != old: + pins.save(path, ctx.settings.set, decision.pins) + updated = spec.with_peers(ctx.doc, pins.wireguard_peers( + decision.pins)) + if spec.peers_of(updated) != spec.peers_of(ctx.doc): + spec.write(self.spec_path, updated, self.validate) + self.system.say(APPLY_HINT) + return int(listing.get("revision", 0)) + + 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) as failure: + self.system.say(f"keel-cloud-node: {failure}; again in" + f" {RETRY}s") + self.system.sleep(RETRY) + + def confirm(self, public_key_value: str, account_key_file: str) -> None: + """The operator admits a node, with the account key""" + doc = spec.load(self.spec_path) + settings = spec.cloud_settings(doc) + message = public_key_error(public_key_value) + if message: + raise NodeError(message) + key = spec.read_secret(account_key_file, self.owner) + client = self.client_factory(settings.endpoint, key, + settings.ca_file) + client.confirm(settings.set, public_key_value) + self.system.say(f"confirmed {public_key_value} in set" + f" {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 = ctx.client.peers(ctx.settings.set) + own = public_key(self.system, self.spec_path) + pinned = pins.load(self.pins_path(), ctx.settings.set) + self.system.say(f"set {ctx.settings.set} at" + f" {ctx.settings.endpoint}, revision" + f" {listing.get('revision')}") + for node in listing.get("nodes", []): + record = node.get("record") 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..ad71795 --- /dev/null +++ b/keel_cloud/node/cli.py @@ -0,0 +1,82 @@ +# 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 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, with" + " the account key") + confirm.add_argument("public_key") + confirm.add_argument("--account-key-file", required=True, metavar="FILE", + help="a file holding the account key, root's and" + " mode 0600") + 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 == "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..7dde52c --- /dev/null +++ b/keel_cloud/node/client.py @@ -0,0 +1,68 @@ +# 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 + + +class CloudError(Exception): + def __init__(self, status: int | None, message: str): + super().__init__(message) + self.status = status + + +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)) + 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: + return json.loads(response.read() 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) -> dict: + return self.call("POST", f"/v1/sets/{set_name}/peers/confirm", + {"public_key": public_key}) + + +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..fe4b64b --- /dev/null +++ b/keel_cloud/node/pins.py @@ -0,0 +1,156 @@ +# 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: + +- its record is valid and names this set; +- its proof verifies with the set's entry secret, which Keel Cloud never + holds, so a record Keel Cloud made up, or changed, does not verify; +- the operator confirmed it (or the set admits automatically, the + per-set choice); +- its overlay addresses clash with no address this node or an admitted + peer already has. + +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_proof +from keel_cloud.record import host_prefixes, validate_record + +STATE_VERSION = 1 +KEEPALIVE = 25 + + +@dataclass(frozen=True) +class Decision: + pins: dict + notes: list = field(default_factory=list) + held: list = field(default_factory=list) + + +def load(path: str, set_name: str) -> dict: + """The pins of one set; empty when there is no state yet""" + try: + with open(path, encoding="utf-8") as stream: + state = json.load(stream) + except FileNotFoundError: + return {} + except (OSError, ValueError) as failure: + raise NodeError(f"{path}: unreadable pin state: {failure}") + if not isinstance(state, dict) or state.get("version") != STATE_VERSION: + raise NodeError(f"{path}: not a pin state of version" + f" {STATE_VERSION}") + return dict(state.get("sets", {}).get(set_name, {})) + + +def save(path: str, set_name: str, pins: dict) -> None: + state = {"version": STATE_VERSION, "sets": {}} + if os.path.exists(path): + try: + with open(path, encoding="utf-8") as stream: + state = json.load(stream) + except (OSError, ValueError) as failure: + raise NodeError(f"{path}: unreadable pin state: {failure}") + sets = dict(state.get("sets", {})) + sets[set_name] = pins + temporary = f"{path}.new" + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump({"version": STATE_VERSION, "sets": sets}, stream, + indent=2, sort_keys=True) + os.replace(temporary, path) + + +def _pin(record: dict) -> dict: + return {key: record[key] for key in + ("overlay", "endpoint", "ts", "appliance", "role", "site")} + + +def _taken(pins: dict, own_overlay: list, skip: str | None = None) -> set: + addresses = {ipaddress.ip_address(a) for a in own_overlay} + for key, pin in pins.items(): + if key != skip: + addresses.update(ipaddress.ip_address(a) for a in pin["overlay"]) + return addresses + + +def _short(key: str) -> str: + return key[:10] + "..." + + +def judge(pins: dict, node: dict, secret: bytes, set_name: str, + own_overlay: list) -> 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"] != set_name: + return None, f"{_short(key)}: names set {record['set']}", False + if not verify_proof(secret, record, node.get("proof")): + return None, (f"{_short(key)}: its proof does not verify with the" + " entry secret; not admitted"), 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 not node.get("admitted"): + return None, (f"{_short(key)} {', '.join(record['overlay'])}: held," + " waiting for the operator's confirmation"), True + clash = _taken(pins, own_overlay) & { + ipaddress.ip_address(a) for a in record["overlay"]} + if clash: + return None, (f"{_short(key)}: {', '.join(map(str, clash))} is" + " already this node's or a pinned peer's"), False + return _pin(record), f"{_short(key)}: admitted and pinned", False + + +def decide(pins: dict, nodes: list, *, own_key: str, own_overlay: list, + secret: bytes, set_name: str) -> Decision: + """The new pins from a listing of the set; `pins` is not changed""" + result = dict(pins) + notes, held = [], [] + for node in nodes: + record = node.get("record") if isinstance(node, dict) else None + if isinstance(record, dict) and record.get("public_key") == own_key: + continue + pin, note, waiting = judge(result, node if isinstance(node, dict) + else {}, secret, set_name, own_overlay) + 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 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..2fd57f2 --- /dev/null +++ b/keel_cloud/node/spec.py @@ -0,0 +1,195 @@ +# 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 + + +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""" + try: + info = os.stat(path) + 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") + with open(path, encoding="ascii") as stream: + value = stream.read().strip() + except OSError as failure: + raise NodeError(f"{path}: {failure.strerror}") from failure + except UnicodeDecodeError as failure: + raise NodeError(f"{path}: not ASCII text") from failure + 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 = [] + 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)) + return Overlay(str(wireguard.get("interface", "wg0")), addresses, + int(wireguard.get("listen_port", DEFAULT_PORT))) + + +def peers_of(doc: dict) -> list: + wireguard = doc["network"]["overlay"]["wireguard"] + return list(wireguard.get("peers") or []) + + +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, 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") + mode = stat.S_IMODE(os.stat(path).st_mode) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + mode) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(dump(doc)) + 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..3c56d6b --- /dev/null +++ b/keel_cloud/proof.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The entry secret and the proof built from 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. A node proves it +holds the secret with an HMAC-SHA256, keyed with the secret, over its +whole record: its public key, its set, its overlay addresses, its +endpoint and a timestamp. + +Keel Cloud stores and relays the proof and never sees the secret, so it +cannot make a valid proof for a key or an address of its own: the nodes +already in the set check every record against the secret they hold. 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 + +DOMAIN = b"keel-cloud proof v1\n" +SECRET_BYTES = 32 +MIN_SECRET_LENGTH = 32 +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.match(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 canonical(record: dict) -> bytes: + """The bytes the proof covers: the record's fields, in one order""" + body = {key: record[key] for key in FIELDS} + return DOMAIN + json.dumps(body, sort_keys=True, separators=(",", ":"), + ensure_ascii=True).encode("ascii") + + +def make_proof(secret: bytes, record: dict) -> str: + return hmac.new(secret, canonical(record), hashlib.sha256).hexdigest() + + +def verify_proof(secret: bytes, record: dict, proof: object) -> bool: + if not isinstance(proof, str) or not PROOF_RE.match(proof): + return False + return hmac.compare_digest(make_proof(secret, record), proof) + + +def proof_error(proof: object) -> str | None: + """The shape the service can check; only a node can check the value""" + if not isinstance(proof, str) or not PROOF_RE.match(proof): + return "proof: 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..70bc02f --- /dev/null +++ b/keel_cloud/record.py @@ -0,0 +1,140 @@ +# 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.match(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.match(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") + 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.isdigit() or not 1 <= int(port) <= 65535: + 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.match(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/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/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/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..362bb16 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,290 @@ +# 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() + agent_a.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_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 32"): + 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() + + +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 32"): + 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 + + +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..e05357e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,247 @@ +# 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"] + node = listing["nodes"][0] + assert node == {"record": record, "proof": proof, "status": "pending", + "admitted": False} + + +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": True}), + api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["enroll"]), + json={"public_key": record["public_key"]})] + for call in calls: + assert (await call).status == 403 + + +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 api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["account"]), + json={"public_key": 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]["admitted"] + + +async def test_confirm_checks_its_body_and_the_node(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + response = await api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["account"]), + json={"public_key": "x"}) + assert response.status == 400 + response = await api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["account"]), + json={"public_key": proven()[0]["public_key"]}) + assert response.status == 404 + + +async def test_auto_admit_is_a_per_set_choice(api, keys, proven): + record, proof = proven() + await enroll(api, keys["enroll"], record, proof) + bad = await api.patch("/v1/sets/shop", headers=auth(keys["account"]), + json={"auto_admit": "yes"}) + assert bad.status == 400 + response = await api.patch("/v1/sets/shop", + headers=auth(keys["account"]), + json={"auto_admit": True}) + 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["nodes"][0]["admitted"] + + +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 api.post("/v1/sets/shop/peers/confirm", + headers=auth(keys["account"]), + json={"public_key": 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"]) +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..f802fbf --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,137 @@ +# 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("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_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" * 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..3160435 --- /dev/null +++ b/tests/test_node_cli.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""keel-cloud-node's command line, and the client's error handling""" + +import io +import urllib.error + +import pytest + +from keel_cloud.node import NodeError +from keel_cloud.node.cli import main +from keel_cloud.node.client import Client, CloudError + + +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"))), + (["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") diff --git a/tests/test_node_spec.py b/tests/test_node_spec.py new file mode 100644 index 0000000..7783d51 --- /dev/null +++ b/tests/test_node_spec.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The agent's reading and writing of the spec""" + +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) + + +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..a67bb85 --- /dev/null +++ b/tests/test_pins.py @@ -0,0 +1,160 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The trust model on the node: proofs, confirmation, pinning""" + +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_proof, new_entry_secret, parse_entry_secret + +OWN = ["fd00:6b65:c1::1"] + + +def node(secret, admitted=True, **changes): + record = make_record(**{"overlay": ["fd00:6b65:c1::2"], **changes}) + return {"record": record, "proof": make_proof(secret, record), + "status": "confirmed" if admitted else "pending", + "admitted": admitted} + + +def decide(secret, existing, *nodes, own_key="own"): + return pins.decide(existing, list(nodes), own_key=own_key, + own_overlay=OWN, secret=secret, set_name="shop") + + +def test_a_confirmed_node_with_a_valid_proof_is_admitted_and_pinned(secret): + peer = node(secret) + decision = decide(secret, {}, peer) + key = peer["record"]["public_key"] + assert decision.pins[key]["overlay"] == ["fd00:6b65:c1::2"] + assert "admitted and pinned" in decision.notes[0] + assert pins.wireguard_peers(decision.pins)[key] == { + "public_key": key, "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, admitted=False) + decision = decide(secret, {}, peer) + assert decision.pins == {} + assert decision.held == [peer["record"]["public_key"]] + assert "waiting for the operator's confirmation" in decision.notes[0] + + +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()) + forged = node(other) + decision = decide(secret, {}, forged) + 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] + + +def test_this_nodes_own_record_is_skipped(secret): + peer = node(secret) + decision = decide(secret, {}, peer, own_key=peer["record"]["public_key"]) + assert decision.pins == {} and decision.notes == [] + + +def test_a_pinned_peer_takes_a_newer_endpoint_only(secret): + first = node(secret) + key = first["record"]["public_key"] + pinned = decide(secret, {}, first).pins + moved = node(secret, public_key=key, ts=NOW + 10, + endpoint="[2001:db8::20]:51820", admitted=False) + 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) + key = first["record"]["public_key"] + pinned = decide(secret, {}, first).pins + record = make_record(public_key=key, overlay=["fd00:6b65:c1::7"], + ts=NOW + 1) + changed = {"record": record, "proof": make_proof(secret, record), + "admitted": True} + 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 impostor["record"]["public_key"] not in decision.pins + assert "already this node's or a pinned peer's" in decision.notes[0] + own = node(secret, overlay=OWN) + assert decide(secret, {}, own).pins == {} + + +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.save(path, "shop", {"k": {"overlay": ["fd00::2"]}}) + pins.save(path, "web", {}) + assert pins.load(path, "shop") == {"k": {"overlay": ["fd00::2"]}} + assert oct(os.stat(path).st_mode & 0o777) == "0o600" + assert json.load(open(path))["version"] == 1 + + +@pytest.mark.parametrize("text", ["{", '{"version": 9}', "[]"]) +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") + + +def test_saving_over_a_damaged_state_stops_too(tmp_path): + path = tmp_path / "pins.json" + path.write_text("{") + with pytest.raises(NodeError): + pins.save(str(path), "shop", {}) + + +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)[peer["record"][ + "public_key"]]["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..8165d1d --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,194 @@ +# 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 + assert cli(tmp_path, "peer", "confirm", "acme", "shop", + record["public_key"])[0] == 0 + assert "confirmed" in cli(tmp_path, "peer", "list", "acme", "shop")[1] + assert cli(tmp_path, "set", "auto-admit", "acme", "shop", "on")[0] == 0 + assert "auto_admit True" 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 + + +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"] + 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..344c182 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,167 @@ +# 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.confirm(account_id, "shop", record["public_key"]) == 2 + assert store.view(account_id, "shop").nodes[0]["status"] == CONFIRMED + assert store.remove(account_id, "shop", record["public_key"]) == 3 + assert store.view(account_id, "shop").nodes == () + for action in (store.confirm, store.remove): + with pytest.raises(StoreError, match="no such node"): + action(account_id, "shop", record["public_key"]) + + +def test_auto_admit_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", True) == 2 + assert store.view(account_id, "shop").auto_admit + + +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) == [] From 810d507f1e46cca2ca0ccda2679defe0206e4aa9 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:01:45 +0000 Subject: [PATCH 2/3] feat: Debian packages, CI through lxc-trixie, and the README Three binary packages from one source (0039): python3-keel-cloud, keel-cloud-api (systemd unit as the keel-cloud system user, [::]:8443, a self-signed development certificate made at installation) and keel-overlay-cloud (the agent, its unit installed disabled). Lintian clean with warnings fatal. CI runs only through Keel-Linux/.github's lxc-trixie.yml: the suite with its 95 percent gate ("coverage / trixie"), and the build, lintian and an install of the service checked as it runs ("build / trixie"). The README lists the recommended answers of 0046 used as defaults until the maintainer decides them, the API, the trust model, TLS with ACME for later, and the real test of 2026-10-02 on three containers. --- .github/workflows/packages.yml | 47 ++++ .github/workflows/tests.yml | 35 +++ README.md | 236 +++++++++++++++++- conf/api.conf | 12 + debian/changelog | 14 ++ debian/control | 71 ++++++ debian/copyright | 24 ++ debian/keel-cloud-api.install | 4 + debian/keel-cloud-api.keel-cloud-api.service | 34 +++ debian/keel-cloud-api.manpages | 2 + debian/keel-cloud-api.postinst | 18 ++ debian/keel-cloud-api.postrm | 12 + debian/keel-cloud-api.sysusers | 1 + debian/keel-overlay-cloud.install | 1 + ...keel-overlay-cloud.keel-cloud-node.service | 30 +++ debian/keel-overlay-cloud.manpages | 1 + debian/python3-keel-cloud.install | 2 + debian/rules | 22 ++ debian/source/format | 1 + man/keel-cloud-api.8 | 34 +++ man/keel-cloud-node.8 | 43 ++++ man/keel-cloud.8 | 43 ++++ scripts/make-dev-certificate | 30 +++ tests/package-smoke.sh | 50 ++++ 24 files changed, 756 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/packages.yml create mode 100644 .github/workflows/tests.yml create mode 100644 conf/api.conf create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/keel-cloud-api.install create mode 100644 debian/keel-cloud-api.keel-cloud-api.service create mode 100644 debian/keel-cloud-api.manpages create mode 100644 debian/keel-cloud-api.postinst create mode 100644 debian/keel-cloud-api.postrm create mode 100644 debian/keel-cloud-api.sysusers create mode 100644 debian/keel-overlay-cloud.install create mode 100644 debian/keel-overlay-cloud.keel-cloud-node.service create mode 100644 debian/keel-overlay-cloud.manpages create mode 100644 debian/python3-keel-cloud.install create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 man/keel-cloud-api.8 create mode 100644 man/keel-cloud-node.8 create mode 100644 man/keel-cloud.8 create mode 100755 scripts/make-dev-certificate create mode 100644 tests/package-smoke.sh 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/README.md b/README.md index adbedd3..164d46a 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,237 @@ 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 set auto-admit`) | `sets.auto_admit` in `keel_cloud/api/store.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}` or `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 proof, its status (`pending` or `confirmed`) and whether it is admitted; `?since=REVISION&wait=SECONDS` is the long poll | +| `POST /v1/sets/{set}/peers/confirm` | account | `{"public_key": ...}`: the operator admits a pending node | + +Accounts and the first 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 peer list acme shop +keel-cloud peer confirm acme shop +keel-cloud set auto-admit acme shop on +keel-cloud key revoke acme +``` + +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.** A new node is `pending` until the +operator confirms it with the account key, on the instance (`keel-cloud +peer confirm`) or from anywhere (`keel cloud confirm KEY +--account-key-file FILE`). A node admits a peer only when it is confirmed +**and** its proof verifies. + +**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, or remove it. A new key for a known +node is a new peer, held like any other. The service keeps the same 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: +it can withhold updates, show records to the operator that are not real, +or mark a node confirmed, but any record it made or changed fails the +proof on every node and is not admitted. It cannot add a peer to a set +without the entry secret, 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. The confirmation step guards against a +leaked entry secret together with a leaked enrollment key; a compromised +service could skip it, which is why the proof, checked on the nodes, is +the line that holds. + +**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 +``` + +`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; each refused the other + until the operator confirmed it. A node's enrollment key was refused + (403) when it tried to confirm. +- The operator confirmed one node with `keel-cloud peer confirm` and the + other with `keel cloud confirm` and the account key; each node's spec + then gained the other as its only peer, with its endpoint, its `/128` + and a keepalive. +- `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`. +- A compromised service was simulated by editing its database: it 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, enrolled, + and was woken by a change on the other node through its long poll, 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..d27be1a --- /dev/null +++ b/debian/keel-cloud-api.keel-cloud-api.service @@ -0,0 +1,34 @@ +[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 +ProtectControlGroups=yes +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..7450ae2 --- /dev/null +++ b/debian/keel-cloud-api.postrm @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Purge removes the certificate the package made and the service's +# state: accounts, key hashes and node records. +if [ "$1" = "purge" ]; then + rm -rf /etc/keel-cloud/tls /var/lib/keel-cloud +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..187ed27 --- /dev/null +++ b/debian/keel-overlay-cloud.keel-cloud-node.service @@ -0,0 +1,30 @@ +[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; it writes nothing else. 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 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/etc/keel -/etc/wireguard -/var/lib/keel +ProtectHome=yes +PrivateTmp=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +RestrictRealtime=yes +LockPersonality=yes + +[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/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..5171a48 --- /dev/null +++ b/man/keel-cloud-node.8 @@ -0,0 +1,43 @@ +.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, with the account key. +.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..c54d281 --- /dev/null +++ b/man/keel-cloud.8 @@ -0,0 +1,43 @@ +.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 on|off +admit new peers without the operator's confirmation, or stop. +.TP +.B peer list \fIACCOUNT\fR \fISET\fR +the set's nodes and their state. +.TP +.B peer confirm \fIACCOUNT\fR \fISET\fR \fIPUBLIC_KEY\fR +admit a pending node. +.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/scripts/make-dev-certificate b/scripts/make-dev-certificate new file mode 100755 index 0000000..31ad412 --- /dev/null +++ b/scripts/make-dev-certificate @@ -0,0 +1,30 @@ +#!/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" +echo "make-dev-certificate: $dir/cert.pem for $san" diff --git a/tests/package-smoke.sh b/tests/package-smoke.sh new file mode 100644 index 0000000..74a3b2c --- /dev/null +++ b/tests/package-smoke.sh @@ -0,0 +1,50 @@ +#!/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=$1 +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" + +echo "package-smoke: keel-cloud-api runs as keel-cloud on [::]:8443, TLS only" From c7b2659d5d77247df7e7768bebbf23b770ec15b0 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:41:18 +0000 Subject: [PATCH 3/3] fix: confirmations nodes can check, and the security review's findings The operator's confirmation was a status the service asserted, so a compromised service could admit any node holding a valid record proof. It is now a proof: keel cloud confirm, run on a node of the set, checks the new node's record proof and makes an HMAC with the entry secret over its set, key and overlay addresses, sent with the account key. Automatic admission is turned on the same way. Every node checks both, so the service can neither confirm a node nor turn admission on by itself; the instance's command line can only turn it off. On the node: a peer's addresses must lie inside this node's overlay prefixes; records dated ahead are refused; peers declared by hand are left alone; keel cloud forget removes a peer for good; one bad record or a listing of the wrong shape no longer stops the agent; the spec is read again after the long poll and keeps its owner; secret files are opened without following links; the pin state is flushed before it replaces the old one. The client follows no redirect and bounds what it reads. Patterns match whole values, so a trailing newline is no longer a second spelling of a key; scope ids and non-ASCII digits are refused. Entry secrets are at least 43 characters. Packaging: tighter sandboxing of both units, the CLI's umask, and a purge that removes only the development certificate it made. The package smoke test now installs from an absolute path, which was the CI failure. --- README.md | 117 +++++++----- debian/keel-cloud-api.keel-cloud-api.service | 5 + debian/keel-cloud-api.postrm | 14 +- ...keel-overlay-cloud.keel-cloud-node.service | 15 +- keel_cloud/api/admin.py | 26 +-- keel_cloud/api/app.py | 45 +++-- keel_cloud/api/store.py | 52 ++++-- keel_cloud/keys.py | 2 +- keel_cloud/node/agent.py | 151 +++++++++++---- keel_cloud/node/cli.py | 25 ++- keel_cloud/node/client.py | 29 ++- keel_cloud/node/pins.py | 172 ++++++++++++------ keel_cloud/node/spec.py | 60 ++++-- keel_cloud/proof.py | 92 +++++++--- keel_cloud/record.py | 12 +- man/keel-cloud-node.8 | 9 +- man/keel-cloud.8 | 9 +- scripts/make-dev-certificate | 2 + tests/package-smoke.sh | 6 +- tests/test_agent.py | 113 +++++++++++- tests/test_app.py | 64 ++++--- tests/test_formats.py | 42 +++++ tests/test_node_cli.py | 56 +++++- tests/test_node_spec.py | 33 +++- tests/test_pins.py | 160 +++++++++++----- tests/test_service.py | 20 +- tests/test_store.py | 23 ++- 27 files changed, 1026 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 164d46a..944dc5e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ here, each kept in one place so that changing one later is cheap: | --- | --- | --- | | 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 set auto-admit`) | `sets.auto_admit` in `keel_cloud/api/store.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` | @@ -49,23 +49,29 @@ Errors are JSON, `{"error": "..."}`. | `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}` or `false` | +| `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 proof, its status (`pending` or `confirmed`) and whether it is admitted; `?since=REVISION&wait=SECONDS` is the long poll | -| `POST /v1/sets/{set}/peers/confirm` | account | `{"public_key": ...}`: the operator admits a pending node | +| `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 | -Accounts and the first keys are made on the instance with the command -line, which runs as `keel-cloud` even when started as root: +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 peer list acme shop -keel-cloud peer confirm acme shop -keel-cloud set auto-admit acme shop on 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`). @@ -90,31 +96,48 @@ admits it (`keel_cloud/node/pins.py`). 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.** A new node is `pending` until the -operator confirms it with the account key, on the instance (`keel-cloud -peer confirm`) or from anywhere (`keel cloud confirm KEY ---account-key-file FILE`). A node admits a peer only when it is confirmed -**and** its proof verifies. +**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, or remove it. A new key for a known -node is a new peer, held like any other. The service keeps the same 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: -it can withhold updates, show records to the operator that are not real, -or mark a node confirmed, but any record it made or changed fails the -proof on every node and is not admitted. It cannot add a peer to a set -without the entry secret, 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. The confirmation step guards against a -leaked entry secret together with a leaked enrollment key; a compromised -service could skip it, which is why the proof, checked on the nodes, is -the line that holds. +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 @@ -151,8 +174,13 @@ keel cloud confirm KEY --account-key-file FILE # the operator, once per new n 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. @@ -206,25 +234,26 @@ 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; each refused the other - until the operator confirmed it. A node's enrollment key was refused - (403) when it tried to confirm. -- The operator confirmed one node with `keel-cloud peer confirm` and the - other with `keel cloud confirm` and the account key; each node's spec - then gained the other as its only peer, with its endpoint, its `/128` - and a keepalive. +- 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`. -- A compromised service was simulated by editing its database: it 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, enrolled, - and was woken by a change on the other node through its long poll, on - an outbound connection only. +- 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. diff --git a/debian/keel-cloud-api.keel-cloud-api.service b/debian/keel-cloud-api.keel-cloud-api.service index d27be1a..db9773f 100644 --- a/debian/keel-cloud-api.keel-cloud-api.service +++ b/debian/keel-cloud-api.keel-cloud-api.service @@ -22,7 +22,12 @@ 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 diff --git a/debian/keel-cloud-api.postrm b/debian/keel-cloud-api.postrm index 7450ae2..88f3da7 100644 --- a/debian/keel-cloud-api.postrm +++ b/debian/keel-cloud-api.postrm @@ -1,10 +1,18 @@ #!/bin/sh set -e -# Purge removes the certificate the package made and the service's -# state: accounts, key hashes and node records. +# 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 /etc/keel-cloud/tls /var/lib/keel-cloud + 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# diff --git a/debian/keel-overlay-cloud.keel-cloud-node.service b/debian/keel-overlay-cloud.keel-cloud-node.service index 187ed27..0dd5a86 100644 --- a/debian/keel-overlay-cloud.keel-cloud-node.service +++ b/debian/keel-overlay-cloud.keel-cloud-node.service @@ -6,7 +6,8 @@ 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; it writes nothing else. It never writes WireGuard's +# 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 @@ -15,16 +16,26 @@ Restart=on-failure RestartSec=30 StateDirectory=keel-cloud-node StateDirectoryMode=0700 +UMask=0077 NoNewPrivileges=yes ProtectSystem=strict -ReadWritePaths=/etc/keel -/etc/wireguard -/var/lib/keel +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/keel_cloud/api/admin.py b/keel_cloud/api/admin.py index 7ca48c2..fcabd94 100644 --- a/keel_cloud/api/admin.py +++ b/keel_cloud/api/admin.py @@ -56,24 +56,25 @@ def build_parser() -> argparse.ArgumentParser: dest="verb", required=True) sets.add_parser("list", help="the account's sets").add_argument( "account") - admit = sets.add_parser("auto-admit", help="admit new peers without the" - " operator's confirmation, or stop doing so") + 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=("on", "off")) + 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") - for verb, text in (("confirm", "admit a pending node"), - ("remove", "forget a node; nodes that admitted it" - " keep it until their operator removes it")): - action = peer.add_parser(verb, help=text) - action.add_argument("account") - action.add_argument("set") - action.add_argument("public_key") + 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 @@ -94,13 +95,11 @@ def run(args, store: Store, out) -> None: 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, args.value == "on") + 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}") - elif command == ("peer", "confirm"): - store.confirm(account, args.set, args.public_key) else: store.remove(account, args.set, args.public_key) @@ -142,6 +141,7 @@ def drop_privileges(user: str = SERVICE_USER, os_module=os) -> None: 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: diff --git a/keel_cloud/api/app.py b/keel_cloud/api/app.py index 19fa877..415c86e 100644 --- a/keel_cloud/api/app.py +++ b/keel_cloud/api/app.py @@ -9,14 +9,18 @@ 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 + 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 + 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. """ @@ -90,7 +94,7 @@ def set_name(request: web.Request) -> str: def query_int(request: web.Request, name: str, top: int) -> int: raw = request.query.get(name, "0") - if not raw.isdigit() or int(raw) > top: + 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) @@ -118,11 +122,17 @@ async def patch_set(request: web.Request) -> web.Response: who = principal(request, ACCOUNT) name = set_name(request) data = await json_body(request) - value = data.get("auto_admit") - if set(data) != {"auto_admit"} or not isinstance(value, bool): - raise ApiError(400, 'the body is {"auto_admit": true or false}') - revision = request.app[STORE].set_auto_admit(who.account_id, name, value) - return web.json_response({"set": name, "auto_admit": value, + 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}) @@ -151,12 +161,14 @@ async def register(request: web.Request) -> web.Response: 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"], - "admitted": node["status"] == CONFIRMED or view.auto_admit} + "confirmation": node["confirmation"]} for node in view.nodes] return {"set": view.name, "revision": view.revision, - "auto_admit": view.auto_admit, "nodes": nodes} + "auto_admit": view.auto_admit is not None, + "auto_admit_proof": view.auto_admit, "nodes": nodes} async def peers(request: web.Request) -> web.Response: @@ -174,15 +186,18 @@ async def peers(request: web.Request) -> web.Response: async def confirm(request: web.Request) -> web.Response: - """The operator admits a node: an account key, never a node's key""" + """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"} or public_key_error(key): - raise ApiError(400, 'the body is {"public_key": KEY}, a WireGuard' - " public key") - revision = request.app[STORE].confirm(who.account_id, name, 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}) diff --git a/keel_cloud/api/store.py b/keel_cloud/api/store.py index e634b0d..859aff9 100644 --- a/keel_cloud/api/store.py +++ b/keel_cloud/api/store.py @@ -2,9 +2,12 @@ """Keel Cloud's state in SQLite What is stored: accounts, the SHA-256 of each API key (never the key), -sets, and per node its public record and the proof that came with it. -What is never stored, because it never reaches the service: an entry -secret, a node's private key, and any application data. +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 @@ -49,7 +52,7 @@ id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL REFERENCES accounts(id), name TEXT NOT NULL, - auto_admit INTEGER NOT NULL DEFAULT 0, + auto_admit TEXT, revision INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL, UNIQUE (account_id, name) @@ -62,6 +65,7 @@ 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) @@ -88,7 +92,7 @@ class Principal: class SetView: name: str revision: int - auto_admit: bool + auto_admit: str | None # the proof, None when admission is manual nodes: tuple @@ -214,14 +218,20 @@ 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": bool(r["auto_admit"]), + 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, value: bool) -> int: + 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 = ?", - (int(bool(value)), row["id"])) + (proof, row["id"])) return self._bump(row["id"]) def revision(self, account_id: int, name: str) -> int: @@ -279,26 +289,32 @@ def _update_node(self, row, node, record: dict, proof: str) -> dict: def _nodes(self, set_id: int) -> list[dict]: rows = self.db.execute( - "SELECT public_key, record, proof, status, created, updated" - " FROM nodes WHERE set_id = ? ORDER BY id", (set_id,)) + "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"], "created": r["created"], - "updated": r["updated"]} for r in rows] + "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"], bool(row["auto_admit"]), + return SetView(name, row["revision"], row["auto_admit"], tuple(self._nodes(row["id"]))) - def confirm(self, account_id: int, name: str, public_key: str) -> int: - """The operator admits a pending node into its set""" + 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 = ?, updated = ? WHERE set_id = ?" - " AND public_key = ?", - (CONFIRMED, self.now(), row["id"], public_key)) + "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}") diff --git a/keel_cloud/keys.py b/keel_cloud/keys.py index d6961a7..f10fba1 100644 --- a/keel_cloud/keys.py +++ b/keel_cloud/keys.py @@ -32,7 +32,7 @@ def new_key(scope: str) -> str: 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.match(key): + if not isinstance(key, str) or not KEY_RE.fullmatch(key): return None for scope, prefix in PREFIXES.items(): if key.startswith(prefix): diff --git a/keel_cloud/node/agent.py b/keel_cloud/node/agent.py index 9d77d35..dcd85fc 100644 --- a/keel_cloud/node/agent.py +++ b/keel_cloud/node/agent.py @@ -1,5 +1,6 @@ # Copyright (c) 2026 KeelLinux maintainers -"""What the node agent does: enroll, sync, run, confirm, entry-secret +"""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. @@ -16,7 +17,14 @@ from keel_cloud.node import NodeError, pins, spec from keel_cloud.node.client import Client, CloudError -from keel_cloud.proof import make_proof, new_entry_secret, parse_entry_secret +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, @@ -30,7 +38,7 @@ WAIT = 50 RETRY = 60 SKIPPED_FLAGS = ("temporary", "deprecated", "tentative", "dadfailed") -APPLY_HINT = ("the spec has new peers: run keel spec apply --system, then" +APPLY_HINT = ("the spec's peers changed: run keel spec apply --system, then" " keel network confirm, as for any overlay change (decision" " 0018)") @@ -77,14 +85,18 @@ def endpoint_address(system: System, overlay: spec.Overlay) -> str | None: except ValueError: links = [] candidates = [] - for link in links: - if link.get("ifname") == overlay.interface: + 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", []): - if "local" not in info or any(info.get(flag) for flag in - SKIPPED_FLAGS): + 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 - address = ipaddress.ip_address(info["local"]) candidates.append((_rank(address), len(candidates), address)) if not candidates: return None @@ -93,6 +105,17 @@ def endpoint_address(system: System, overlay: spec.Overlay) -> str | None: 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 @@ -174,23 +197,31 @@ def validate(self, path: str) -> str: def sync(self, since: int = 0, wait: int = 0) -> int: """Admit what the set proposes, and write the pinned peers""" ctx = self.context() - listing = ctx.client.peers(ctx.settings.set, since, wait) - own = public_key(self.system, self.spec_path) + 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() - old = pins.load(path, ctx.settings.set) - decision = pins.decide(old, listing.get("nodes", []), own_key=own, - own_overlay=ctx.overlay.addresses, - secret=ctx.secret, set_name=ctx.settings.set) + 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 != old: - pins.save(path, ctx.settings.set, decision.pins) - updated = spec.with_peers(ctx.doc, pins.wireguard_peers( - decision.pins)) - if spec.peers_of(updated) != spec.peers_of(ctx.doc): + 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 int(listing.get("revision", 0)) + return listing["revision"] def pins_path(self) -> str: os.makedirs(self.state_dir, mode=0o700, exist_ok=True) @@ -211,24 +242,71 @@ def run(self, rounds: int | None = None) -> None: self.enroll(current["endpoint"]) enrolled_at, sent = now, current revision = self.sync(revision, self.wait) - except (CloudError, NodeError) as failure: + 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, with the account key""" - doc = spec.load(self.spec_path) - settings = spec.cloud_settings(doc) + """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) - key = spec.read_secret(account_key_file, self.owner) - client = self.client_factory(settings.endpoint, key, - settings.ca_file) - client.confirm(settings.set, public_key_value) - self.system.say(f"confirmed {public_key_value} in set" - f" {settings.set}") + 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""" @@ -250,14 +328,15 @@ def entry_secret(self) -> None: def status(self) -> None: ctx = self.context() - listing = ctx.client.peers(ctx.settings.set) + 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) + 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.get('revision')}") - for node in listing.get("nodes", []): - record = node.get("record") or {} + 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 diff --git a/keel_cloud/node/cli.py b/keel_cloud/node/cli.py index ad71795..790f471 100644 --- a/keel_cloud/node/cli.py +++ b/keel_cloud/node/cli.py @@ -8,6 +8,9 @@ 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 @@ -49,12 +52,20 @@ def build_parser() -> argparse.ArgumentParser: 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, with" - " the account key") + 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") - confirm.add_argument("--account-key-file", required=True, metavar="FILE", - help="a file holding the account key, root's and" - " mode 0600") + 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 @@ -72,6 +83,10 @@ def main(argv: list[str] | None = None, agent_factory=Agent) -> int: 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: diff --git a/keel_cloud/node/client.py b/keel_cloud/node/client.py index 7dde52c..7ae34d8 100644 --- a/keel_cloud/node/client.py +++ b/keel_cloud/node/client.py @@ -11,6 +11,8 @@ TIMEOUT = 30 LONG_POLL_MARGIN = 15 +# 256 nodes of about 1 KiB each, with room to spare +MAX_ANSWER = 1024 * 1024 class CloudError(Exception): @@ -19,6 +21,14 @@ def __init__(self, status: int | None, message: str): 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): @@ -28,7 +38,7 @@ def __init__(self, endpoint: str, key: str, ca_file: str | None = 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)) + urllib.request.HTTPSHandler(context=context), NoRedirect()) self.opener = opener def call(self, method: str, path: str, body: dict | None = None, @@ -41,7 +51,11 @@ def call(self, method: str, path: str, body: dict | None = None, "User-Agent": f"keel-cloud-node/{__version__}"}) try: with self.opener.open(request, timeout=timeout) as response: - return json.loads(response.read() or b"{}") + 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: @@ -56,9 +70,16 @@ 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) -> dict: + 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}) + {"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: diff --git a/keel_cloud/node/pins.py b/keel_cloud/node/pins.py index fe4b64b..d1269f1 100644 --- a/keel_cloud/node/pins.py +++ b/keel_cloud/node/pins.py @@ -2,22 +2,25 @@ """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: - -- its record is valid and names this set; -- its proof verifies with the set's entry secret, which Keel Cloud never - holds, so a record Keel Cloud made up, or changed, does not verify; -- the operator confirmed it (or the set admits automatically, the - per-set choice); -- its overlay addresses clash with no address this node or an admitted - peer already has. +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. +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 @@ -26,11 +29,31 @@ from dataclasses import dataclass, field from keel_cloud.node import NodeError -from keel_cloud.proof import verify_proof +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) @@ -40,37 +63,49 @@ class Decision: held: list = field(default_factory=list) -def load(path: str, set_name: str) -> dict: - """The pins of one set; empty when there is no state yet""" +@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 {} + 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: + 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 dict(state.get("sets", {}).get(set_name, {})) + return state -def save(path: str, set_name: str, pins: dict) -> None: - state = {"version": STATE_VERSION, "sets": {}} - if os.path.exists(path): - try: - with open(path, encoding="utf-8") as stream: - state = json.load(stream) - except (OSError, ValueError) as failure: - raise NodeError(f"{path}: unreadable pin state: {failure}") - sets = dict(state.get("sets", {})) - sets[set_name] = pins +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" - descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, - 0o600) + 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) @@ -79,31 +114,51 @@ def _pin(record: dict) -> dict: ("overlay", "endpoint", "ts", "appliance", "role", "site")} -def _taken(pins: dict, own_overlay: list, skip: str | None = None) -> set: - addresses = {ipaddress.ip_address(a) for a in own_overlay} - for key, pin in pins.items(): - if key != skip: - addresses.update(ipaddress.ip_address(a) for a in pin["overlay"]) - return addresses - - def _short(key: str) -> str: return key[:10] + "..." -def judge(pins: dict, node: dict, secret: bytes, set_name: str, - own_overlay: list) -> tuple[dict | None, str | None, bool]: +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"] != set_name: + if record["set"] != rules.set_name: return None, f"{_short(key)}: names set {record['set']}", False - if not verify_proof(secret, record, node.get("proof")): + 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"]: @@ -112,28 +167,33 @@ def judge(pins: dict, node: dict, secret: bytes, set_name: str, if record["ts"] <= pinned["ts"]: return None, None, False return _pin(record), None, False - if not node.get("admitted"): + 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 - clash = _taken(pins, own_overlay) & { - ipaddress.ip_address(a) for a in record["overlay"]} - if clash: - return None, (f"{_short(key)}: {', '.join(map(str, clash))} is" - " already this node's or a pinned peer's"), False + 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_key: str, own_overlay: list, - secret: bytes, set_name: str) -> Decision: +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: - record = node.get("record") if isinstance(node, dict) else None - if isinstance(record, dict) and record.get("public_key") == own_key: + 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 - pin, note, waiting = judge(result, node if isinstance(node, dict) - else {}, secret, set_name, own_overlay) if pin is not None: result[record["public_key"]] = pin if note: @@ -143,6 +203,12 @@ def decide(pins: dict, nodes: list, *, own_key: str, own_overlay: list, 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 = {} diff --git a/keel_cloud/node/spec.py b/keel_cloud/node/spec.py index 2fd57f2..922970a 100644 --- a/keel_cloud/node/spec.py +++ b/keel_cloud/node/spec.py @@ -50,6 +50,7 @@ class Overlay: interface: str addresses: list listen_port: int + networks: tuple = () def load(path: str) -> dict: @@ -103,19 +104,27 @@ def cloud_settings(doc: dict) -> CloudSettings: def read_secret(path: str, owner: int = 0) -> str: - """A secret file's content; it must be `owner`'s and mode 0600""" + """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: - info = os.stat(path) + 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") - with open(path, encoding="ascii") as stream: - value = stream.read().strip() - except OSError as failure: - raise NodeError(f"{path}: {failure.strerror}") from failure + 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 @@ -130,7 +139,7 @@ def overlay(doc: dict) -> Overlay: raise NodeError("network.overlay.wireguard.address: Keel Cloud" " exchanges the overlay's keys, so the spec declares" " the overlay first") - addresses = [] + addresses, networks = [], [] for key in ("address", "ipv4_address"): if wireguard.get(key) is not None: try: @@ -139,8 +148,10 @@ def overlay(doc: dict) -> Overlay: 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))) + int(wireguard.get("listen_port", DEFAULT_PORT)), + tuple(networks)) def peers_of(doc: dict) -> list: @@ -148,6 +159,20 @@ def peers_of(doc: dict) -> list: 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 @@ -172,18 +197,25 @@ def dump(doc: dict) -> str: 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, 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. + 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") - mode = stat.S_IMODE(os.stat(path).st_mode) - descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, - mode) + 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: diff --git a/keel_cloud/proof.py b/keel_cloud/proof.py index 3c56d6b..22502f9 100644 --- a/keel_cloud/proof.py +++ b/keel_cloud/proof.py @@ -1,16 +1,23 @@ # Copyright (c) 2026 KeelLinux maintainers -"""The entry secret and the proof built from it (decision 0046) +"""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. A node proves it -holds the secret with an HMAC-SHA256, keyed with the secret, over its -whole record: its public key, its set, its overlay addresses, its -endpoint and a timestamp. - -Keel Cloud stores and relays the proof and never sees the secret, so it -cannot make a valid proof for a key or an address of its own: the nodes -already in the set check every record against the secret they hold. The -secret is 32 random bytes, so it cannot be guessed from the proofs. +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 @@ -21,11 +28,15 @@ from keel_cloud.record import FIELDS -DOMAIN = b"keel-cloud proof v1\n" +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 -MIN_SECRET_LENGTH = 32 -SECRET_RE = re.compile(r"^[A-Za-z0-9_-]+$") -PROOF_RE = re.compile(r"^[0-9a-f]{64}$") +# 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: @@ -36,7 +47,7 @@ def new_entry_secret() -> str: 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.match(value): + 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" @@ -45,25 +56,54 @@ def parse_entry_secret(text: str) -> bytes: 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 proof covers: the record's fields, in one order""" - body = {key: record[key] for key in FIELDS} - return DOMAIN + json.dumps(body, sort_keys=True, separators=(",", ":"), - ensure_ascii=True).encode("ascii") + """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 hmac.new(secret, canonical(record), hashlib.sha256).hexdigest() + return _mac(secret, canonical(record)) def verify_proof(secret: bytes, record: dict, proof: object) -> bool: - if not isinstance(proof, str) or not PROOF_RE.match(proof): - return False - return hmac.compare_digest(make_proof(secret, record), proof) + 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) -> str | None: +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.match(proof): - return "proof: 64 lower case hexadecimal digits, an HMAC-SHA256" + 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 index 70bc02f..cb99c8a 100644 --- a/keel_cloud/record.py +++ b/keel_cloud/record.py @@ -38,14 +38,14 @@ def label_error(name: str, value: Any) -> str | None: - if not isinstance(value, str) or not LABEL_RE.match(value): + 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.match(value): + 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 @@ -55,6 +55,8 @@ def overlay_address(value: Any) -> ipaddress.IPv4Address | \ """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" @@ -96,7 +98,9 @@ def endpoint_error(value: Any) -> str | None: if not isinstance(value, str) or ":" not in value: return message host, _, port = value.rpartition(":") - if not port.isdigit() or not 1 <= int(port) <= 65535: + 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: @@ -104,7 +108,7 @@ def endpoint_error(value: Any) -> str | None: except ValueError: return message return None - if ":" in host or not HOST_RE.match(host): + if ":" in host or not HOST_RE.fullmatch(host): return message return None diff --git a/man/keel-cloud-node.8 b/man/keel-cloud-node.8 index 5171a48..8ccd661 100644 --- a/man/keel-cloud-node.8 +++ b/man/keel-cloud-node.8 @@ -30,7 +30,14 @@ register this node, or bring its record up to date. 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, with the account key. +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. diff --git a/man/keel-cloud.8 b/man/keel-cloud.8 index c54d281..3558acc 100644 --- a/man/keel-cloud.8 +++ b/man/keel-cloud.8 @@ -26,15 +26,14 @@ revoke a key. .B set list \fIACCOUNT\fR the account's sets. .TP -.B set auto-admit \fIACCOUNT\fR \fISET\fR on|off -admit new peers without the operator's confirmation, or stop. +.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 confirm \fIACCOUNT\fR \fISET\fR \fIPUBLIC_KEY\fR -admit a pending node. -.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. diff --git a/scripts/make-dev-certificate b/scripts/make-dev-certificate index 31ad412..8b85b01 100755 --- a/scripts/make-dev-certificate +++ b/scripts/make-dev-certificate @@ -27,4 +27,6 @@ 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/package-smoke.sh b/tests/package-smoke.sh index 74a3b2c..0318692 100644 --- a/tests/package-smoke.sh +++ b/tests/package-smoke.sh @@ -5,7 +5,7 @@ # and only its hash is stored. Run as root in a throwaway container (CI). set -euo pipefail -dir=$1 +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 \ @@ -47,4 +47,8 @@ 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 index 362bb16..d929930 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -133,12 +133,71 @@ def test_a_node_with_the_wrong_entry_secret_is_refused_by_the_set( agent_c = agent_for(intruder) agent_a.enroll() agent_c.enroll() - agent_a.confirm(intruder.machine.key, account_key_file(tmp_path, cloud)) + 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 @@ -210,7 +269,7 @@ 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 32"): + with pytest.raises(NodeError, match="at least 43"): agent_a.enroll() @@ -225,6 +284,51 @@ def test_a_record_keel_cannot_make_is_reported(tmp_path, cloud, pair): 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"): @@ -243,7 +347,7 @@ def test_entry_secret_is_made_once_and_printed(tmp_path, pair): 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 32"): + with pytest.raises(NodeError, match="at least 43"): agent_a.entry_secret() @@ -271,6 +375,9 @@ def test_the_endpoint_is_ipv6_first_global_first(tmp_path): 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): diff --git a/tests/test_app.py b/tests/test_app.py index e05357e..5065438 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -70,9 +70,10 @@ async def test_a_node_enrolls_and_lists_its_set(api, keys, proven): 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", - "admitted": False} + "confirmation": None} async def test_an_update_answers_200(api, keys, proven): @@ -150,56 +151,78 @@ async def test_the_enrollment_key_cannot_manage_or_confirm(api, keys, 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": True}), + json={"auto_admit": False}), api.post("/v1/sets/shop/peers/confirm", headers=auth(keys["enroll"]), - json={"public_key": record["public_key"]})] + 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 api.post("/v1/sets/shop/peers/confirm", - headers=auth(keys["account"]), - json={"public_key": record["public_key"]}) + 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]["admitted"] + 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": "x"}) + json={"public_key": record["public_key"]}) assert response.status == 400 - response = await api.post("/v1/sets/shop/peers/confirm", - headers=auth(keys["account"]), - json={"public_key": proven()[0]["public_key"]}) + response = await confirm(api, keys["account"], + proven()[0]["public_key"]) assert response.status == 404 -async def test_auto_admit_is_a_per_set_choice(api, keys, proven): +@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) - bad = await api.patch("/v1/sets/shop", headers=auth(keys["account"]), - json={"auto_admit": "yes"}) - assert bad.status == 400 response = await api.patch("/v1/sets/shop", headers=auth(keys["account"]), - json={"auto_admit": True}) + 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["nodes"][0]["admitted"] + 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): @@ -217,9 +240,7 @@ async def test_long_poll_returns_when_the_set_changes(api, keys, proven): "/v1/sets/shop/peers?since=1&wait=5", headers=auth(keys["enroll"]))) await asyncio.sleep(0.05) assert not poll.done() - await api.post("/v1/sets/shop/peers/confirm", - headers=auth(keys["account"]), - json={"public_key": record["public_key"]}) + await confirm(api, keys["account"], record["public_key"]) listing = await (await asyncio.wait_for(poll, 2)).json() assert listing["revision"] == 2 @@ -232,7 +253,8 @@ async def test_long_poll_times_out_with_the_same_listing(api, keys, proven): assert (await response.json())["revision"] == 1 -@pytest.mark.parametrize("query", ["since=-1", "wait=56", "wait=x"]) +@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) diff --git a/tests/test_formats.py b/tests/test_formats.py index f802fbf..01fbe82 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -52,6 +52,22 @@ def test_public_keys_have_the_shape_wg_prints(): 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): @@ -107,12 +123,38 @@ def test_the_proof_covers_every_field(secret, field, value): 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") diff --git a/tests/test_node_cli.py b/tests/test_node_cli.py index 3160435..62a82b1 100644 --- a/tests/test_node_cli.py +++ b/tests/test_node_cli.py @@ -2,13 +2,14 @@ """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 Client, CloudError +from keel_cloud.node.client import MAX_ANSWER, Client, CloudError, NoRedirect class Recorder: @@ -37,6 +38,11 @@ def fresh(): (["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", ())), ]) @@ -89,4 +95,50 @@ def test_the_client_reads_the_services_error_message(): 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") + 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 index 7783d51..f7b0cc8 100644 --- a/tests/test_node_spec.py +++ b/tests/test_node_spec.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 KeelLinux maintainers """The agent's reading and writing of the spec""" +import ipaddress import os import pytest @@ -89,8 +90,36 @@ 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) + 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(): diff --git a/tests/test_pins.py b/tests/test_pins.py index a67bb85..22f5002 100644 --- a/tests/test_pins.py +++ b/tests/test_pins.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 KeelLinux maintainers """The trust model on the node: proofs, confirmation, pinning""" +import ipaddress import json import os @@ -8,31 +9,47 @@ from conftest import NOW, make_record, wg_key from keel_cloud.node import NodeError, pins -from keel_cloud.proof import make_proof, new_entry_secret, parse_entry_secret +from keel_cloud.proof import ( + make_auto_admit, + make_confirmation, + make_proof, + new_entry_secret, + parse_entry_secret, +) -OWN = ["fd00:6b65:c1::1"] +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, admitted=True, **changes): +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 admitted else "pending", - "admitted": admitted} + "status": "confirmed" if confirmed else "pending", + "confirmation": make_confirmation(secret, record) + if confirmed else None} -def decide(secret, existing, *nodes, own_key="own"): - return pins.decide(existing, list(nodes), own_key=own_key, - own_overlay=OWN, secret=secret, set_name="shop") +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) - key = peer["record"]["public_key"] - assert decision.pins[key]["overlay"] == ["fd00:6b65:c1::2"] + 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] == { - "public_key": key, "endpoint": "[2001:db8::10]:51820", + 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} @@ -43,18 +60,45 @@ def test_a_peer_without_endpoint_waits_to_be_reached(secret): def test_a_pending_node_is_held_for_the_operator(secret): - peer = node(secret, admitted=False) + peer = node(secret, confirmed=False) decision = decide(secret, {}, peer) - assert decision.pins == {} - assert decision.held == [peer["record"]["public_key"]] + 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()) - forged = node(other) - decision = decide(secret, {}, forged) + decision = decide(secret, {}, node(other)) assert decision.pins == {} assert "does not verify" in decision.notes[0] @@ -71,20 +115,36 @@ def test_invalid_records_and_other_sets_are_refused(secret): 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) - decision = decide(secret, {}, peer, own_key=peer["record"]["public_key"]) + 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 = first["record"]["public_key"] + key = key_of(first) pinned = decide(secret, {}, first).pins - moved = node(secret, public_key=key, ts=NOW + 10, - endpoint="[2001:db8::20]:51820", admitted=False) + 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, @@ -94,12 +154,9 @@ def test_a_pinned_peer_takes_a_newer_endpoint_only(secret): def test_a_pinned_peer_keeps_its_addresses(secret): first = node(secret) - key = first["record"]["public_key"] pinned = decide(secret, {}, first).pins - record = make_record(public_key=key, overlay=["fd00:6b65:c1::7"], - ts=NOW + 1) - changed = {"record": record, "proof": make_proof(secret, record), - "admitted": True} + 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] @@ -109,12 +166,30 @@ 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 impostor["record"]["public_key"] not in decision.pins + 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=OWN) + 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 @@ -128,33 +203,34 @@ def test_decide_does_not_change_the_pins_it_is_given(secret): 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.save(path, "shop", {"k": {"overlay": ["fd00::2"]}}) - pins.save(path, "web", {}) - assert pins.load(path, "shop") == {"k": {"overlay": ["fd00::2"]}} + 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}', "[]"]) +@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") - - -def test_saving_over_a_damaged_state_stops_too(tmp_path): - path = tmp_path / "pins.json" - path.write_text("{") with pytest.raises(NodeError): - pins.save(str(path), "shop", {}) + 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)[peer["record"][ - "public_key"]]["allowed_ips"] + 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 index 8165d1d..81229d7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -135,11 +135,12 @@ def test_the_command_line_manages_sets_and_peers(tmp_path, capsys): store.close() code, text = cli(tmp_path, "peer", "list", "acme", "shop") assert record["public_key"] in text and "pending" in text - assert cli(tmp_path, "peer", "confirm", "acme", "shop", - record["public_key"])[0] == 0 - assert "confirmed" in cli(tmp_path, "peer", "list", "acme", "shop")[1] - assert cli(tmp_path, "set", "auto-admit", "acme", "shop", "on")[0] == 0 + 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", @@ -147,6 +148,15 @@ def test_the_command_line_manages_sets_and_peers(tmp_path, capsys): 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"]) @@ -174,7 +184,7 @@ 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"] + "setuid", "umask"] fake = FakeOs(0) admin.drop_privileges("no-such-user-here", fake) assert fake.calls == [] diff --git a/tests/test_store.py b/tests/test_store.py index 344c182..a776ffa 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -118,22 +118,29 @@ def test_confirm_admits_and_remove_forgets(store, account): account_id, _ = account record = make_record() store.register(account_id, record, "a" * 64) - assert store.confirm(account_id, "shop", record["public_key"]) == 2 - assert store.view(account_id, "shop").nodes[0]["status"] == CONFIRMED + 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 == () - for action in (store.confirm, store.remove): - with pytest.raises(StoreError, match="no such node"): - action(account_id, "shop", record["public_key"]) + 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_and_unknown_sets(store, account): +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", True) == 2 - assert store.view(account_id, "shop").auto_admit + 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):