275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""Root-side Debian/PVE bootstrap, staged and invoked by bootstrap-node."""
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
HOME = Path("/home/adam")
|
|
|
|
|
|
def checked(command, *, data=None, capture=False, **kwargs):
|
|
# A Git hook or remote can emit credentials. Report the operation, never its output.
|
|
result = subprocess.run(command, input=data, text=True, stderr=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE if capture else subprocess.DEVNULL, **kwargs)
|
|
if result.returncode:
|
|
raise RuntimeError(f"{Path(command[0]).name} failed (exit {result.returncode})")
|
|
return result.stdout
|
|
|
|
|
|
def repository_endpoint(url):
|
|
if not isinstance(url, str) or any(c.isspace() or ord(c) < 32 for c in url):
|
|
raise ValueError("Invalid SSH repository URL")
|
|
if url.startswith("ssh://"):
|
|
parsed = urlsplit(url)
|
|
if (parsed.password is not None or not parsed.path.strip("/") or parsed.fragment
|
|
or parsed.query or parsed.port == 0):
|
|
raise ValueError("Invalid SSH repository URL")
|
|
host, port = parsed.hostname, parsed.port
|
|
else:
|
|
match = re.fullmatch(r"(?:[\w.-]+@)?(\[[a-fA-F0-9:]+\]|[\w.-]+):(.+)", url, re.ASCII)
|
|
host, port = (match[1].strip("[]"), None) if match else (None, None)
|
|
if not host or not re.fullmatch(r"[a-zA-Z0-9:][a-zA-Z0-9.:-]*", host):
|
|
raise ValueError("An SSH repository host is required")
|
|
return host, f"[{host}]:{port}" if port not in (None, 22) else host
|
|
|
|
|
|
def existing(path):
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
return b""
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.geteuid():
|
|
raise ValueError(f"Unsafe file: {path.name}")
|
|
return path.read_bytes()
|
|
|
|
|
|
def install(path, content, mode):
|
|
existing(path)
|
|
fd, tmp = tempfile.mkstemp(dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, "wb") as stream:
|
|
stream.write(content)
|
|
os.fchmod(stream.fileno(), mode)
|
|
os.replace(tmp, path)
|
|
finally:
|
|
Path(tmp).unlink(missing_ok=True)
|
|
|
|
|
|
def validate_credentials(spec, endpoint):
|
|
key = spec["operator_key"].strip()
|
|
if len(key.splitlines()) != 1 or not key.startswith(("ssh-", "ecdsa-", "sk-")):
|
|
raise ValueError("Expected one operator public key")
|
|
checked(["ssh-keygen", "-lf", "-"], data=key + "\n")
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
private = Path(temp) / "deploy"
|
|
install(private, (spec["deploy_key"].rstrip() + "\n").encode(), 0o600)
|
|
public = checked(["ssh-keygen", "-y", "-P", "", "-f", str(private)], capture=True)
|
|
if public.startswith("sk-") or public.split()[:2] == key.split()[:2]:
|
|
raise ValueError("Use a separate unattended deploy key")
|
|
pins = Path(temp) / "known_hosts"
|
|
pins.write_text(spec["known_hosts"])
|
|
matches = checked(["ssh-keygen", "-F", endpoint, "-f", str(pins)], capture=True)
|
|
trusted = [line for line in matches.splitlines() if not line.startswith(("#", "@revoked "))]
|
|
if not trusted:
|
|
raise ValueError("No trusted host key for the repository")
|
|
for pin in trusted:
|
|
checked(["ssh-keygen", "-lf", "-"], data=pin + "\n")
|
|
|
|
|
|
def configure_ssh(home, spec, host):
|
|
directory = home / ".ssh"
|
|
directory.mkdir(mode=0o700, exist_ok=True)
|
|
info = directory.lstat()
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid():
|
|
raise ValueError("Unsafe SSH directory")
|
|
directory.chmod(0o700)
|
|
key = spec["operator_key"].strip()
|
|
authorized = directory / "authorized_keys"
|
|
content = existing(authorized)
|
|
identity = key.split()[:2]
|
|
present = False
|
|
for line in content.decode(errors="replace").splitlines():
|
|
fields = line.split()
|
|
if fields[:2] == identity:
|
|
present = True
|
|
elif fields and not line.lstrip().startswith("#") and identity[1] in fields:
|
|
raise ValueError("Existing operator key has SSH authorization restrictions; inspect it")
|
|
if not present:
|
|
content = content + (b"\n" if content and not content.endswith(b"\n") else b"")
|
|
content += key.encode() + b"\n"
|
|
# Git also evaluates includeIf for the separate gitdirs of recursive submodules.
|
|
config = home / ".gitconfig"
|
|
old = existing(config)
|
|
gitdir = f"/srv/{spec['node_name']}/.git"
|
|
prefix = directory / ("apex-" + spec["node_name"])
|
|
include = (f'[includeIf "gitdir:{gitdir}"]\n\tpath = {prefix}.gitconf\n'
|
|
f'[includeIf "gitdir:{gitdir}/"]\n\tpath = {prefix}.gitconf\n').encode()
|
|
for suffix in ("key", "hosts", "sshconf", "gitconf"):
|
|
existing(Path(f"{prefix}.{suffix}"))
|
|
write_repository_credentials(prefix, spec, host)
|
|
install(Path(f"{prefix}.gitconf"),
|
|
f"[core]\n\tsshCommand = ssh -F {prefix}.sshconf\n".encode(), 0o600)
|
|
if include not in old:
|
|
install(config, old + b"\n" + include, 0o600)
|
|
install(authorized, content, 0o600)
|
|
|
|
|
|
def write_repository_credentials(prefix, spec, host):
|
|
policy = (f"Host {host}\n IdentitiesOnly yes\n"
|
|
f" IdentityFile {prefix}.key\n UserKnownHostsFile {prefix}.hosts\n"
|
|
" GlobalKnownHostsFile /dev/null\nHost *\n StrictHostKeyChecking yes\n"
|
|
" BatchMode yes\n")
|
|
for suffix, content in (("key", spec["deploy_key"]), ("hosts", spec["known_hosts"]),
|
|
("sshconf", policy)):
|
|
install(Path(f"{prefix}.{suffix}"), (content.rstrip() + "\n").encode(), 0o600)
|
|
|
|
|
|
def verify_and_clone(spec, host, account):
|
|
srv = Path("/srv")
|
|
if not srv.exists():
|
|
srv.mkdir()
|
|
srv.chmod(0o755)
|
|
if srv.resolve() != srv or srv.stat().st_uid != 0 or srv.stat().st_mode & 0o022:
|
|
raise RuntimeError("/srv must be a root-controlled directory")
|
|
with tempfile.TemporaryDirectory(prefix=".apex-credentials-", dir=srv) as temp:
|
|
stage = Path(temp)
|
|
prefix = stage / "git"
|
|
write_repository_credentials(prefix, spec, host)
|
|
for path in stage.iterdir():
|
|
os.chown(path, account.pw_uid, account.pw_gid)
|
|
stage.chmod(0o711)
|
|
|
|
def git(*args):
|
|
return checked(["runuser", "-u", "adam", "--", "env", "GIT_TERMINAL_PROMPT=0",
|
|
f"GIT_SSH_COMMAND=ssh -F {prefix}.sshconf", "git", *args],
|
|
capture=True, cwd="/")
|
|
|
|
try:
|
|
git("ls-remote", "--exit-code", "--", spec["repo_url"], "HEAD")
|
|
except RuntimeError:
|
|
raise RuntimeError("Git access check failed; check the repository URL, deploy-key "
|
|
"grants and pinned host key") from None
|
|
checkout(srv, spec["node_name"], spec["repo_url"], account, git)
|
|
|
|
|
|
def prepare_packages():
|
|
if not Path("/etc/debian_version").is_file():
|
|
raise RuntimeError("Debian or Proxmox VE is required")
|
|
missing = []
|
|
for package in ("git", "sudo", "openssh-client"):
|
|
result = subprocess.run(["dpkg-query", "-W", "-f=${Status}", package], text=True,
|
|
capture_output=True)
|
|
if result.returncode or result.stdout.split()[1:] != ["ok", "installed"]:
|
|
missing.append(package)
|
|
if missing:
|
|
checked(["apt-get", "update"])
|
|
checked(["apt-get", "install", "-y", "--no-remove", "--no-install-recommends", *missing])
|
|
|
|
|
|
def prepare_account():
|
|
try:
|
|
account = pwd.getpwnam("adam")
|
|
except KeyError:
|
|
if os.path.lexists(HOME):
|
|
raise RuntimeError("Existing /home/adam requires inspection") from None
|
|
checked(["useradd", "--create-home", "--user-group", "--shell", "/bin/bash", "adam"])
|
|
account = pwd.getpwnam("adam")
|
|
if (not 1000 <= account.pw_uid < 65534 or account.pw_gid == 0
|
|
or account.pw_dir != str(HOME) or HOME.resolve() != HOME
|
|
or HOME.stat().st_uid != account.pw_uid or HOME.stat().st_mode & 0o022
|
|
or not HOME.is_dir()):
|
|
raise RuntimeError("Existing adam account or home requires inspection")
|
|
groups = "sudo"
|
|
if not Path("/usr/bin/pveversion").exists():
|
|
checked(["groupadd", "--system", "--force", "docker"])
|
|
groups += ",docker"
|
|
checked(["usermod", "--shell", "/bin/bash", "--append", "--groups", groups, "adam"])
|
|
return account
|
|
|
|
|
|
def checkout(srv, node, url, account, git):
|
|
destination = srv / node
|
|
if os.path.lexists(destination):
|
|
info = destination.lstat()
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != account.pw_uid:
|
|
raise RuntimeError("Existing checkout path requires inspection")
|
|
|
|
def query(*args):
|
|
return git("-C", str(destination), *args).strip()
|
|
|
|
if query("rev-parse", "--show-toplevel") != str(destination) or query(
|
|
"config", "--get", "remote.origin.url") != url:
|
|
raise RuntimeError("Existing checkout does not match the requested repository")
|
|
if any(line.startswith(("-", "U")) for line in query("submodule", "status", "--recursive").splitlines()):
|
|
raise RuntimeError("Existing checkout has uninitialized or conflicting submodules")
|
|
return
|
|
with tempfile.TemporaryDirectory(prefix=".apex-bootstrap-", dir=srv) as staging:
|
|
stage = Path(staging)
|
|
stage.chmod(0o711)
|
|
clone = stage / "checkout"
|
|
clone.mkdir()
|
|
os.chown(clone, account.pw_uid, account.pw_gid)
|
|
try:
|
|
git("clone", "--recurse-submodules", "--", url, str(clone))
|
|
except RuntimeError:
|
|
raise RuntimeError("Recursive clone failed; check access to pinned submodules "
|
|
"and available disk space") from None
|
|
clone.rename(destination)
|
|
|
|
|
|
def bootstrap(spec):
|
|
node = spec["node_name"]
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,252}", node):
|
|
raise ValueError("Invalid node checkout name")
|
|
host, endpoint = repository_endpoint(spec["repo_url"])
|
|
if not all(isinstance(spec[k], str) and spec[k].strip()
|
|
for k in ("known_hosts", "deploy_key", "operator_key")):
|
|
raise ValueError("Missing SSH credentials")
|
|
if os.geteuid() != 0:
|
|
raise RuntimeError("Root login is required")
|
|
os.umask(0o077)
|
|
os.environ.clear()
|
|
os.environ.update(PATH="/usr/sbin:/usr/bin:/sbin:/bin", LC_ALL="C",
|
|
DEBIAN_FRONTEND="noninteractive", HOME="/root")
|
|
# Serialize account, credential and checkout changes from competing bootstraps.
|
|
with open("/run/apex-bootstrap.lock", "a") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
prepare_packages()
|
|
validate_credentials(spec, endpoint)
|
|
account = prepare_account()
|
|
verify_and_clone(spec, host, account)
|
|
child = os.fork()
|
|
if child == 0:
|
|
try:
|
|
os.initgroups("adam", account.pw_gid)
|
|
os.setgid(account.pw_gid)
|
|
os.setuid(account.pw_uid)
|
|
os.chdir(HOME)
|
|
configure_ssh(HOME, spec, host)
|
|
except Exception as error:
|
|
print(f"SSH setup failed: {error}", file=sys.stderr, flush=True)
|
|
os._exit(1)
|
|
os._exit(0)
|
|
if os.waitpid(child, 0)[1]:
|
|
raise RuntimeError("Unable to install SSH credentials")
|
|
policy = "adam ALL=(ALL:ALL) NOPASSWD: ALL\n"
|
|
checked(["visudo", "-cf", "-"], data=policy)
|
|
install(Path("/etc/sudoers.d/99-apex-adam"), policy.encode(), 0o440)
|
|
checked(["visudo", "-c"])
|
|
checked(["runuser", "-u", "adam", "--", "sudo", "-n", "true"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
bootstrap(json.load(sys.stdin))
|
|
except (ValueError, KeyError, RuntimeError, OSError) as error:
|
|
sys.exit(f"Bootstrap failed: {error}")
|