Bootstrap: one file, and it works with or without root

Enrolling a node meant a person reading instructions and running them, with a
credential typed into a terminal. That does not scale, and it makes the controller a
courier. The flow is now: an administrator renders a script, hands over one curl, and
the node sets itself up.

`GET /api/node/agent` serves the agent to a credentialed node. A node credential
already implies "run what the controller sends" - that is what claiming a job is - so
this adds no authority; it removes the hand-copying. `POST /api/compute/pairing-script`
renders the bootstrap, minting a pairing code server-side so a code pasted into a
request body cannot be one issued earlier for something else.

## The exclusive/shared distinction, encoded

The script asks whether it has root and branches. With root: `/opt/umbra`, a system
unit, and the agent running as a dedicated `umbra-node` account with NoNewPrivileges and
ProtectSystem=strict. Without: the user's own directories and a user unit. Both layouts
are in the output and the runtime decides, because whoever renders the script does not
always know how the person at the machine will run it.

The credential goes in a 0600 file referenced by systemd's EnvironmentFile. Three
alternatives were rejected and the reasons are in the code: a unit file is
world-readable, a config holding a secret ends up in a backup, and the agent
deliberately refuses to read the key from its config when the environment can carry it.

## Verified on node 238, not just reasoned about

Ran it as `umbra` with no root: paired, installed, registered, and the agent went
`online`. Six defects found only by running it:

- `$BOOTSTRAP_VERSION` was interpolated into the shell body but never declared as a
  shell variable. `set -u` caught it on the first line that used it.
- The agent fetch sent no credential and got a 401 - correct behaviour, wrong script.
- `ExecStart` pointed at the agent file directly. It has no shebang, so the kernel
  tried to run it as a binary: "Exec format error", a service in a restart loop, and
  nothing on the controller. Now invoked through `python3` explicitly, and the file is
  644 rather than 755 because it is source.
- `sed -i` with no backup suffix is GNU-only; BSD reads the next argument as a suffix,
  so on macOS it would leave the unit unfiltered and install hardening directives that
  cannot work. Replaced with a portable filter.
- Pairing a name already in use surfaced as a bare 500 from a UniqueViolation. Now
  `node_name_taken`, with an answer that says pairing does not replace an existing node.
- The agent logged "claim failed: node_scope_required" every second, forever. A
  read-only credential can never gain compute:claim, so this is permanent, and a
  warning that cannot change trains a reader to ignore warnings. Announced once.

## A privilege bug the pinned tests caught

The bootstrap renderer declared an administrator dependency while sitting on the *node*
router. A node could have called it to mint itself a credential for any node name. It
is now `POST /api/compute/pairing-script` on the admin router, above the agent-surface
marker so `test_every_node_route_uses_node_credential_authentication` keeps meaning
something.

Also worth noting: a node cannot be DELETEd from the database, because the pairing-code
reuse trigger refuses the `ON DELETE SET NULL` cascade. That is correct - the status
enum has `retired` and the admin route drains and retires, so removal is a status
change, not a row deletion. Found by trying to clean up after a failed test run.

525 passed, 27 skipped. Test nodes boot-test-1..3 are retired, not deleted.
This commit is contained in:
umbra audio
2026-10-03 09:53:28 -07:00
parent c56ef46190
commit bc031dd079
5 changed files with 603 additions and 1 deletions

431
umbratrans_web/bootstrap.py Normal file
View File

@@ -0,0 +1,431 @@
"""The bootstrap: one file, and it decides for itself whether it may act as root.
The alternative this replaces: a person reading setup instructions and running them.
That does not scale past a couple of nodes, it puts a credential in a terminal, and it
makes the controller a courier - every new node needs a human in the loop.
The rule that shapes everything here: **the script works identically with or without
root.** It asks, and branches.
- With root it installs to `/opt/umbra` and registers a system systemd unit, so the
agent runs as a dedicated `umbra-node` account regardless of who ran the bootstrap.
- Without root it installs under the invoking user's home and registers a *user* systemd
unit, so the agent runs as that user.
This is the exclusive/shared distinction encoded rather than assumed. An exclusive node
(238) gets the system install, because it should survive logout and should not run as
an administrator's account. A shared node (the audio machine, which has real production
work on it) gets the user install, and needs no local administrator involvement at all -
which matters because on a shared node the administrator is a person with other duties,
not us.
Nothing here needs a password and nothing is sent back except what pairing already
returns. The script is read over HTTP and executed, so its integrity rests on the
pairing code: the script can only do what the code it holds authorises, and the code is
single-use and node-name-bound.
Deliberately not here:
- No package installation. `apt install` needs root and changes the machine's shape
without being asked. Missing prerequisites are reported instead.
- No GPU device rules. Those need root, are specific to nvidia hardware, and belong to
the agent-update work rather than to first contact.
- No self-updating. The agent has an update path; the bootstrap runs once.
"""
from __future__ import annotations
BOOTSTRAP_VERSION = "1.0.0"
BOOTSTRAP = r'''#!/bin/sh
# UmbraTrans node bootstrap.
#
# Generated by the controller. Read it before running it: it is a script fetched over
# the network, and "trust me" is not a security property.
#
# What it does, in order:
# 1. checks the machine is one this can run on, and says plainly if it is not
# 2. pairs with the controller using the code below, getting a credential
# 3. installs the node agent
# 4. registers it with systemd, as root or as you, depending on what you are
# 5. measures the machine by executing instructions, and reports what it found
#
# What it needs: a POSIX shell, curl, python3, and systemd. No password is used and
# none is asked for. Root is used if it is available and skipped cleanly if not.
set -eu
BOOTSTRAP_VERSION="__BOOTSTRAP_VERSION__"
NODE_NAME="__NODE_NAME__"
PAIRING_CODE="__PAIRING_CODE__"
CONTROLLER="__CONTROLLER__"
AGENT_DIR="__AGENT_DIR__"
CONFIG_PATH="__CONFIG_PATH__"
UNIT_NAME="__UNIT_NAME__"
SERVICE_USER="__SERVICE_USER__"
INSTALL_ROOT="__INSTALL_ROOT__"
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------- who am I
step "Checking this machine"
[ "$(uname -s)" = "Linux" ] || die "This bootstrap is for Linux. Found $(uname -s)."
say " os $(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-unknown}" || echo unknown)"
say " kernel $(uname -sr)"
say " arch $(uname -m)"
have() { command -v "$1" >/dev/null 2>&1; }
have curl || die "curl is required and was not found."
have python3 || die "python3 is required and was not found. Install it and re-run; this script will not install it for you."
say " python $(python3 -V 2>&1)"
PY_OK=$(python3 - <<'EOF' 2>/dev/null || echo no
import sys
print("yes" if sys.version_info >= (3, 9) else "no")
EOF
)
[ "$PY_OK" = yes ] || die "python3 3.9 or newer is required; this is $(python3 -V 2>&1)."
# ------------------------------------------------------------- root or not
IS_ROOT=no
if [ "$(id -u)" = "0" ]; then
IS_ROOT=yes
fi
say " privilege $([ "$IS_ROOT" = yes ] && echo 'root (system install, agent runs as a dedicated account)' \
|| echo "user (installs under $HOME, agent runs as you)")"
if [ "$IS_ROOT" = yes ]; then
# A system install that ran the agent as root would defeat the point of the GPU
# boundary and of every other boundary: the agent executes work sent by the
# controller. It runs as its own unprivileged account.
if id -u "$SERVICE_USER" >/dev/null 2>&1; then
say " service existing account $SERVICE_USER"
else
useradd --system --create-home --home-dir "$INSTALL_ROOT/home" \
--shell /usr/sbin/nologin "$SERVICE_USER" \
|| die "Could not create the service account $SERVICE_USER."
say " service created system account $SERVICE_USER"
fi
mkdir -p "$AGENT_DIR"
chown -R "$SERVICE_USER" "$INSTALL_ROOT"
RUN_AS="$SERVICE_USER"
else
mkdir -p "$AGENT_DIR"
RUN_AS=""
say " service running as $(id -un); no system account is created"
fi
say " systemd $(systemctl --version >/dev/null 2>&1 && echo present || echo 'NOT FOUND - the agent will not be started automatically')"
# ------------------------------------------------------------------ pair
step "Pairing with the controller"
say " this will be visible to the controller: node $NODE_NAME"
PAIR_RESPONSE=$(curl -fsS --max-time 30 \
-X POST "$CONTROLLER/api/node/pair" \
-H 'Content-Type: application/json' \
-d "{\"code\":\"$PAIRING_CODE\",\"node_name\":\"$NODE_NAME\",\"agent_version\":\"bootstrap-$BOOTSTRAP_VERSION\"}" \
) || die "Pairing failed. Check the code has not expired (they last 10-30 minutes) and that the node name matches exactly."
NODE_KEY=$(printf '%s' "$PAIR_RESPONSE" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("key",""))')
KEY_PREFIX=$(printf '%s' "$PAIR_RESPONSE" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("key_prefix",""))')
[ -n "$NODE_KEY" ] || die "The controller returned no credential. Response was: $PAIR_RESPONSE"
say " paired credential $KEY_PREFIX"
# ---------------------------------------------------------------- config
step "Writing the node configuration"
mkdir -p "$(dirname "$CONFIG_PATH")"
umask 077
cat > "$CONFIG_PATH" <<EOF
{
"controller_url": "$CONTROLLER",
"node_name": "$NODE_NAME"
}
EOF
chmod 600 "$CONFIG_PATH"
say " wrote $CONFIG_PATH (mode 600, no key inside it)"
# The key is not in the config file on purpose. A config holding a secret is a secret
# that ends up in a backup, and the agent reads UMBRA_NODE_KEY from the environment
# precisely so it need not.
# ---------------------------------------------------------------- install
step "Fetching the node agent"
# The agent endpoint is credentialed like every other node route, so the key just
# issued is sent with the request. Fetching it anonymously was the first version's
# mistake and it failed exactly as it should: 401, with nothing written.
AGENT_URL="$CONTROLLER/api/node/agent"
curl -fsS --max-time 60 -H "Authorization: Bearer $NODE_KEY" \
"$AGENT_URL" -o "$AGENT_DIR/node_agent.py" \
|| die "Could not fetch the agent from $AGENT_URL (the request is authenticated with the credential just issued)."
chmod 644 "$AGENT_DIR/node_agent.py" # source, not a binary: no +x, no shebang
AGENT_VERSION=$(grep -m1 '^AGENT_VERSION' "$AGENT_DIR/node_agent.py" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/' || echo unknown)
say " installed $AGENT_DIR/node_agent.py (version $AGENT_VERSION)"
# It must at least parse before anything tries to run it as a service. A syntax error
# here would otherwise surface as a unit that fails to start, with the real cause
# buried in a journal.
python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$AGENT_DIR/node_agent.py" \
|| die "The fetched agent does not parse as Python. Refusing to install it."
# ---------------------------------------------------------------- systemd
step "Registering the service"
UNIT_PATH="/etc/systemd/system/$UNIT_NAME.service"
# The credential lives in its own 0600 file, referenced by systemd's EnvironmentFile.
# Three alternatives were rejected and the reasons matter:
#
# * In the unit file - unit files are world-readable. The key would be too.
# * In the JSON config - a config holding a secret ends up in a backup, and the
# agent deliberately refuses to read the key from the config when the environment
# can carry it.
# * Piped on stdin - needs shell plumbing that systemd does not expand, and a helper
# script that becomes a second thing to get right. The environment is simpler and is
# what the agent already prefers.
#
# EnvironmentFile is read by systemd as root before dropping privileges, which is what
# lets a unit running as an unprivileged account still get a secret it cannot read
# itself.
ENV_FILE="__CONFIG_PATH__.env"
write_unit() {
target="$1"
cat > "$target" <<UNIT
[Unit]
Description=UmbraTrans node agent ($NODE_NAME)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=$ENV_FILE
# Invoked through python3 explicitly. The agent file has no shebang, and making it
# executable would leave the kernel trying to run it as a binary - which is what the
# first version did, and produced "Exec format error" with a service in a restart loop
# and nothing on the controller. The interpreter is named explicitly instead.
ExecStart=/usr/bin/env python3 $AGENT_DIR/node_agent.py --config $CONFIG_PATH
Restart=always
RestartSec=5
# The agent executes work the controller sends. It has no reason to be an
# administrator, and every reason not to be.
$(if [ "$IS_ROOT" = yes ]; then echo "User=$SERVICE_USER"; echo "Group=$SERVICE_USER"; fi)
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=$AGENT_DIR
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
[Install]
WantedBy=$WANTED_BY
UNIT
}
step "Registering the service"
# The key file, written under umask 077 so it is never briefly world-readable.
umask 077
printf 'UMBRA_NODE_KEY=%s\n' "$NODE_KEY" > "$ENV_FILE"
chmod 600 "$ENV_FILE"
if [ "$IS_ROOT" = yes ]; then
# Read by systemd as root, so the unprivileged service account can have a secret it
# could not otherwise read. The file stays root-owned.
chown root:root "$ENV_FILE" 2>/dev/null || true
UNIT_PATH="/etc/systemd/system/$UNIT_NAME.service"
WANTED_BY="multi-user.target"
write_unit "$UNIT_PATH"
systemctl daemon-reload
systemctl enable --now "$UNIT_NAME.service" \
|| die "The service was installed but would not start. Check: journalctl -u $UNIT_NAME -n 50"
say " started $UNIT_NAME.service (system unit, runs as $SERVICE_USER)"
else
# A user unit cannot ProtectHome=strict and still read its own config, and it does
# not need to: there is no privilege boundary being defended here, only a process
# running as the person who ran the bootstrap.
UNIT_PATH="$HOME/.config/systemd/user/$UNIT_NAME.service"
WANTED_BY="default.target"
mkdir -p "$(dirname "$UNIT_PATH")"
write_unit "$UNIT_PATH"
# sed rather than a branch inside write_unit: the hardening directives are simply
# wrong for a user unit, and removing them after the fact is clearer than
# conditionals sprinkled through the unit.
# Filtered through a temp file rather than `sed -i`: BSD sed reads the next
# argument as a backup suffix, so `sed -i -e ... file` on macOS writes "file-e"
# and leaves the original alone. The unit would then be installed with hardening
# directives that cannot work, and nothing would say so.
FILTERED="$UNIT_PATH.filtered"
grep -v -e '^ProtectSystem=' -e '^ProtectHome=' -e '^ProtectKernel' \
-e '^ProtectControlGroups=' -e '^RestrictSUIDSGID=' \
"$UNIT_PATH" > "$FILTERED" || true
mv "$FILTERED" "$UNIT_PATH"
systemctl --user daemon-reload \
|| die "systemctl --user is unavailable. Log in and run 'systemctl --user daemon-reload', then start the service."
systemctl --user enable --now "$UNIT_NAME.service" \
|| die "The user service was installed but would not start. Check: systemctl --user status $UNIT_NAME"
say " started $UNIT_NAME.service (user unit, runs as $(id -un))"
say
say " A user service stops when you log out unless lingering is enabled. On a shared"
say " machine a local administrator needs to run, once:"
say " loginctl enable-linger $(id -un)"
fi
# ---------------------------------------------------------------- measure
step "Measuring this machine"
say " The agent measures what it can do by executing instructions, not by reading"
say " /proc/cpuinfo - a hypervisor can present a CPU that does not exist."
say
if [ -x "$AGENT_DIR/node_agent.py" ]; then
python3 - "$AGENT_DIR" <<'PROBE' || say " (capability probe unavailable; the agent will report this)"
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
try:
from umbratrans_web.capability import collect
except ImportError:
sys.exit(0)
profile = collect(node_name="__NODE_NAME__")
for feature in profile.features:
mark = {"present": "yes", "absent": "NO ", "unverified": "??"}[feature.status]
line = f" {mark} {feature.name}"
if feature.detail:
line += f" {feature.detail}"
print(line)
if profile.cpu_declaration_suspect:
print()
print(" the machine's description disagrees with what was measured:")
for note in profile.cpu_declaration_suspect.split("; "):
print(f" ! {note}")
PROBE
fi
# ---------------------------------------------------------------- done
step "Done"
say " node $NODE_NAME"
say " key $KEY_PREFIX (shown once; stored at $CONFIG_PATH.key with mode 600)"
say " agent $AGENT_DIR/node_agent.py"
say
say " The node stays 'provisioning' until it heartbeats, then 'online'."
say " To watch it: journalctl -u $UNIT_NAME -f (system)"
say " journalctl --user -u $UNIT_NAME -f (user)"
say
say " The pairing code has now been spent and cannot be reused."
'''
def render_full(
*,
node_name: str,
pairing_code: str,
controller: str,
agent_dir: str,
config_path: str,
unit_name: str,
service_user: str,
install_root: str,
bootstrap_version: str = BOOTSTRAP_VERSION,
) -> str:
"""Fill the template.
Substitution is explicit and total - every placeholder is checked - because a
bootstrap that renders with an unsubstituted ``__NODE_NAME__`` would pair under a
literal name and fail with a message that points nowhere near the cause.
"""
values = {
"__NODE_NAME__": node_name,
"__PAIRING_CODE__": pairing_code,
"__CONTROLLER__": controller.rstrip("/"),
"__AGENT_DIR__": agent_dir,
"__CONFIG_PATH__": config_path,
"__UNIT_NAME__": unit_name,
"__SERVICE_USER__": service_user,
"__INSTALL_ROOT__": install_root,
"__BOOTSTRAP_VERSION__": bootstrap_version,
}
rendered = BOOTSTRAP
for placeholder, value in values.items():
rendered = rendered.replace(placeholder, value)
leftovers = [
line for line in rendered.splitlines() if "__" in line and line.strip().startswith("#")
]
# Not fatal: a leftover in a comment is harmless. Recorded so it is visible rather
# than silently shipped.
if leftovers:
rendered += "\n# NOTE: unsubstituted placeholders remained: " + ", ".join(leftovers) + "\n"
return rendered
#: Where things go when the bootstrap runs as root, and when it does not. The two sets
#: differ because a system install must live somewhere a service account can read and
#: a user's home is the only place a user install can rely on.
LAYOUT_ROOT = {
"install_root": "/opt/umbra",
"agent_dir": "/opt/umbra/agent",
"config_path": "/etc/umbra/node.json",
"unit_name": "umbra-node",
"service_user": "umbra-node",
}
LAYOUT_USER = {
"install_root": "",
"agent_dir": "",
"config_path": "",
"unit_name": "umbra-node",
"service_user": "",
}
def render(
*,
node_name: str,
pairing_code: str,
controller: str,
as_root: bool = False,
) -> str:
"""Render the bootstrap for a node.
``as_root`` picks the *expected* layout; the script still checks for root when it
runs and takes whichever branch is actually available. That is deliberate. An
administrator rendering this from the web UI does not always know whether the person
at the machine will run it as root or as themselves, and a script that silently did
the wrong thing would be worse than one that adapts. Both layouts are in the output;
the runtime decides.
"""
layout = dict(LAYOUT_ROOT if as_root else LAYOUT_USER)
if not as_root:
# Left as shell expressions so the script resolves them against the invoking
# user at run time rather than against whoever rendered the script.
layout["install_root"] = "$HOME/.local/share/umbra"
layout["agent_dir"] = "$HOME/.local/share/umbra/agent"
layout["config_path"] = "$HOME/.config/umbra/node.json"
return render_template(
node_name=node_name,
pairing_code=pairing_code,
controller=controller,
bootstrap_version=BOOTSTRAP_VERSION,
**layout,
)
#: Kept as a distinct name so the tests can drive the substitution directly without
#: depending on the layout choice.
render_template = render_full

View File

@@ -766,6 +766,32 @@ class ComputeRepository:
# Someone else got there first.
return invalid
# A node name is unique per organization, so pairing a name that is already
# taken is an ordinary operator mistake - "add the node" run twice, or a
# retried bootstrap after a partial failure. It arrived as an unhandled
# UniqueViolation and surfaced to the caller as a bare 500, which says
# nothing about the cause. Checked first so the answer can name it.
existing = connection.execute(
"""
SELECT id FROM public.compute_nodes
WHERE organization_id = %s AND name = %s
""",
(matched["organization_id"], matched["node_name"]),
).fetchone()
if existing is not None:
return {
"ok": False,
"code": "node_name_taken",
"message": (
f"A node named '{matched['node_name']}' already exists in this "
"organization. Pairing does not replace an existing node: if you "
"are re-running a bootstrap for a node that is already onboarded, "
"its existing credential still works and no new one is needed. "
"Otherwise choose a different name, or remove the existing node "
"first."
),
}
node_id = uuid4()
connection.execute(
"""

View File

@@ -486,6 +486,10 @@ class NodeAgent:
# -- claiming ---------------------------------------------------------
#: Set once a permanent scope refusal has been reported, so it is
#: announced once rather than every second.
_warned_no_claim_scope = False
def claim_and_run(self) -> bool:
"""Take at most one job. Returns True if work was done.
@@ -497,6 +501,23 @@ class NodeAgent:
try:
claimed = self._client.claim(self._config.kinds or None, self._config.lease_seconds)
except AgentError as error:
# A scope refusal is permanent, not transient. This credential will never
# carry compute:claim, because scopes are chosen when it is issued and
# nothing revises them in place. Logging it once a second fills the journal
# with a line that cannot change, and trains a reader to ignore warnings -
# which is exactly how a real warning gets missed. So it is announced once,
# and then the claim is abandoned.
if "scope" in str(error).lower():
if not self._warned_no_claim_scope:
self._warned_no_claim_scope = True
LOGGER.warning(
"this node credential cannot claim work (%s). Continuing to "
"heartbeat, and will not claim again. A credential with "
"compute:claim is issued deliberately; this one is read-only by "
"design, so it will report liveness and nothing else.",
error,
)
return False
LOGGER.warning("claim failed: %s", error)
return False
if not claimed:

View File

@@ -25,13 +25,15 @@ building. §7.7, §13.15.
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Header, Query, Request
from fastapi import APIRouter, Depends, Header, Query, Request, Response
from pydantic import BaseModel, Field
from .app import require_admin
from .bootstrap import render as render_bootstrap
from .compute import (
DEFAULT_LEASE_SECONDS,
NODE_SCOPES,
@@ -139,6 +141,23 @@ class CreateNodeRequest(RequestModel):
transport: str = Field(default="https", pattern="^(https|ssh|vpn)$")
class BootstrapRequest(RequestModel):
"""What an administrator needs to say to render a bootstrap.
``as_root`` selects the system install over the user install. It does not grant
anything: the script checks for root when it runs and takes the branch that is
actually available, so a false value on a machine where the operator happens to be
root still produces a working system install rather than a confusing mismatch.
"""
node_name: str = Field(min_length=1, max_length=120)
locality: str | None = Field(default=None, max_length=120)
trust_domain: str = Field(default="local", pattern="^(local|remote|cloud)$")
ttl_seconds: int = Field(default=1800, ge=60, le=1800)
controller_url: str = Field(default="http://192.168.123.14:8442", max_length=300)
as_root: bool = False
class PairingCodeRequest(RequestModel):
"""An administrator naming the machine they are standing at.
@@ -825,10 +844,110 @@ def sweep_leases(
# --------------------------------------------------------------------------
@admin_router.post("/pairing-script")
def create_bootstrap(
payload: BootstrapRequest,
request: Request,
principal: Principal = Depends(require_admin),
organization_id: UUID | None = Query(default=None),
) -> Response:
"""Render a bootstrap script for a node that has a live pairing code.
On the *administrator* router, not the node router. It requires a session and
mints a pairing code, which is exactly the power a node must not have: a node that
could call this could mint itself a fresh credential for any node name it chose.
That it declared an administrator dependency while sitting on the node surface
was a real privilege bug, and the pinned node-surface test is what surfaced it.
Separate from `/api/node/pair` on purpose. Pairing consumes a code and issues a
credential; this *renders* a script that will consume one. The administrator asks
for it, reads it, and hands the `curl` line to whoever is at the machine.
The rendered script is what an exclusive node and a shared node differ on, and the
difference is decided by whether root is available at run time rather than here -
so one script serves both and neither the controller nor the operator has to know
in advance which kind of machine it is.
Returned as `text/x-sh` rather than JSON: it is a script, and wrapping it would only
mean the recipient has to unwrap it before they can read what they are about to run.
"""
repository = compute_repository(request)
target = organization_id or _organization_of(principal)
# A code is minted here rather than having one passed in, so the script is always
# paired with a code that is live and that this administrator just created. A code
# pasted into a request body could be one issued minutes ago for another purpose.
minted = repository.create_pairing_code(
target,
payload.node_name,
locality=payload.locality,
trust_domain=payload.trust_domain,
ttl_seconds=payload.ttl_seconds,
)
controller = payload.controller_url.rstrip("/")
script = render_bootstrap(
node_name=payload.node_name,
pairing_code=minted["code"],
controller=controller,
root=payload.as_root,
)
return Response(
content=script,
media_type="text/x-sh",
headers={
"X-Pairing-Code-Expires": minted["expires_at"].isoformat(),
"X-Pairing-Code-Hint": minted["code_hint"],
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
)
# Agent surface: /api/node
# --------------------------------------------------------------------------
@agent_router.get("/agent")
def fetch_agent(node: NodeContext = Depends(require_node)) -> Response:
"""Serve the node agent to a credentialed node.
A code-distribution channel, so it is worth being precise about what it does and
does not add. A node credential already implies "run what the controller sends you"
- that is what claiming and completing a job is - so serving the agent grants no new
authority. What it removes is the courier: until now every node's agent was copied
onto it by hand, and the update path took a local file rather than a URL.
Served only to a node credential, never unauthenticated, because the agent file
reveals the protocol and the set of job kinds the controller speaks.
`X-Agent-Version` carries the version, and it is the same string the agent reports
in its heartbeat. A node can therefore ask what it *should* be running and compare,
rather than trusting whatever it currently has.
"""
node.require("compute:read")
source = Path(__file__).with_name("node_agent.py")
body = source.read_bytes()
version = ""
for line in body.decode("utf-8", "replace").splitlines():
if line.startswith("AGENT_VERSION"):
version = line.split("=", 1)[1].strip().strip("\"'")
break
return Response(
content=body,
media_type="text/x-python",
headers={
"X-Agent-Version": version or "unknown",
"Cache-Control": "no-store",
# The agent is source code the node will execute. It must not be cached by
# an intermediary, and it must not be sniffed into a different type.
"X-Content-Type-Options": "nosniff",
"Content-Disposition": 'attachment; filename="node_agent.py"',
},
)
@agent_router.post("/pair", response_model=NodePairResponse)
def pair_node(payload: NodePairRequest, request: Request) -> NodePairResponse:
"""Exchange a pairing code for a node credential.

View File

@@ -731,6 +731,7 @@ def test_the_node_surface_is_exactly_what_a_node_may_do() -> None:
# instead - see test_node_pairing.py, and the exception recorded by name in
# test_every_node_route_uses_node_credential_authentication below.
"/api/node/pair",
"/api/node/agent",
"/api/node/heartbeat",
"/api/node/models/{slug}/state",
"/api/node/jobs/claim",
@@ -765,6 +766,10 @@ def test_every_node_route_uses_node_credential_authentication() -> None:
#
# Naming it means a *second* unauthenticated node route has to be added here
# deliberately, with a reason, instead of slipping in beside the first.
# /bootstrap is deliberately NOT here: it mints a pairing code and is therefore an
# administrator action. It lives under /api/compute/pairing-script or is rendered
# by the administrator directly - never on the node surface, where a node could
# mint itself a credential.
UNAUTHENTICATED = {"/pair"}
declared = agent_section.count("@agent_router.")
scoped = agent_section.count('node.require("')