#!/usr/bin/env python3 """Bootstrap a fleet node through a trusted OpenSSH connection.""" import argparse import json import re import shlex import subprocess import sys from pathlib import Path from urllib.parse import urlsplit def repository(url): if any(ch.isspace() or ord(ch) < 32 for ch in url): raise ValueError("invalid repository URL") if url.startswith("ssh://"): parts = urlsplit(url) if parts.password is not None or parts.query or parts.fragment: raise ValueError("expected an SSH repository URL without password, query or fragment") host, port, path = parts.hostname, parts.port, parts.path else: match = re.fullmatch(r"(?:[\w.-]+@)?(\[[\da-fA-F:]+\]|[\w.-]+):(.+)", url) if not match: raise ValueError("expected git@host:path or ssh://user@host[:port]/path") host, path = match.groups() host, port = host.strip("[]"), None if not host or not re.fullmatch(r"[a-zA-Z0-9:][a-zA-Z0-9.:-]*", host) or not path: raise ValueError("invalid Git host or repository path") return f"[{host}]:{port}" if port not in (None, 22) else host, Path(path).name.removesuffix(".git") def keygen(args, data=None): return subprocess.run(["ssh-keygen", *args], input=data or b"", capture_output=True) def bootstrap(args): endpoint, basename = repository(args.repo) name = args.node_name if args.node_name is not None else basename if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", name): raise ValueError("invalid node name") operator = args.operator_key.read_text().strip() fields = operator.split() if (len(operator.splitlines()) != 1 or len(fields) < 2 or keygen(["-lf", "-"], (" ".join(fields[:2]) + "\n").encode()).returncode): raise ValueError("operator key must contain one plain SSH public key") derived = keygen(["-y", "-P", "", "-f", str(args.deploy_key.resolve())]) if derived.returncode or derived.stdout.split()[0].startswith(b"sk-"): raise ValueError("deploy key must be a standalone private key with no passphrase and owner-only permissions") if derived.stdout.split()[:2] == [v.encode() for v in fields[:2]]: raise ValueError("operator and deploy keys must be different") matches = keygen(["-F", endpoint, "-f", str(args.known_hosts.resolve())]) pins = [line for line in matches.stdout.decode().splitlines() if line and not line.startswith("#")] if matches.returncode or not pins or not any( not pin.startswith("@revoked ") and keygen(["-lf", "-"], (pin + "\n").encode()).returncode == 0 for pin in pins ): raise ValueError(f"no usable known_hosts pin for {endpoint}") payload = json.dumps(dict(operator_key=operator, deploy_key=args.deploy_key.read_text(), known_hosts="\n".join(pins) + "\n", repo_url=args.repo, node_name=name)) helper = Path(__file__).resolve().with_name("bootstrap-node-remote.py").read_text() # Package management must never consume the credential payload on standard input. remote = """set -e if [ "$(id -u)" = 0 ]; then set --; else set -- sudo -n --; fi if ! command -v python3 >/dev/null; then test -f /etc/debian_version "$@" apt-get -o DPkg::Lock::Timeout=60 update