phase: 33_gateway_route_switching
Phase 33 completion pass is done. Final report:
## Phase 33 — completion report
**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.
### Work in this pass
1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).
2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.
3. **Documentation consistency fixes** (task 08 criterion 6):
- `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
- `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
- `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
- `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.
### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported
### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
This commit is contained in:
+297
-31
@@ -2,13 +2,19 @@
|
||||
"""host_agent.py — read-only host metrics agent for the homelab dashboards.
|
||||
|
||||
**Stdlib only, one file, Python 3.10+.** Copy it to a host, run it, and the dashboard can
|
||||
read that host. It never writes anything anywhere: every number comes from reading
|
||||
/proc, /sys and statvfs, and the HTTP server only answers GET.
|
||||
read that host. Every number comes from reading /proc, /sys and statvfs, and the HTTP
|
||||
server answers GET — with exactly one exception: 1.4.0's `POST /route`, the fleet's one
|
||||
narrow write (switching a route's `#off` marker). Its gates are the client certificate and
|
||||
`--allow-writes`, and it never touches a config file itself: it dials the socket-activated
|
||||
root helper (`--routectl-socket`) once and passes the helper's JSON through verbatim.
|
||||
|
||||
GET /health -> {"ok": true, "hostname": ..., "version": ...}
|
||||
GET /info -> slow-changing identity (os, kernel, cpu model, cores, gpu list)
|
||||
GET /snapshot -> everything, one JSON document (see SCHEMA below)
|
||||
GET /schema -> this docstring's SCHEMA section, for self-documenting deploys
|
||||
POST /route -> the one write: forward exactly {id, action, dry_run} to the
|
||||
routectl helper and answer its JSON verbatim (1.4.0; certificate
|
||||
plus --allow-writes, in that order — a token-mode POST is refused)
|
||||
|
||||
Design rules
|
||||
------------
|
||||
@@ -89,6 +95,20 @@ Design rules
|
||||
are simply absent — `null` a pass that could not run), and `--no-resolve` removes the key in
|
||||
silence the way `--no-smart` does; `--no-proxy` removes it too, because a box that collects no
|
||||
proxy has no declared names to look up.
|
||||
* **The one write is a forward, not a file edit.** `POST /route` (1.4.0) is the only non-GET
|
||||
path and the only thing this process ever sends to an upstream. The order is the design:
|
||||
the client certificate (the handshake is the identity — a bearer token never gets this
|
||||
path), then `--allow-writes` (the operator's decision, off by default), then the body
|
||||
(exactly `{id, action, dry_run}`, ≤ 4096 bytes, an `action` of `disable`/`enable`, an
|
||||
`id` of at most 256 characters — a refused body never reaches the peer), then the
|
||||
helper's socket. The dial is `AF_UNIX` with a 30 s budget (the helper may run `nginx -t`
|
||||
and a reload), one newline-delimited request, one line back, and **never a retry — a
|
||||
retry of a write is a second reload**: every failure is a 502 (an unreadable or
|
||||
oversized answer, a timeout, or the errno word) and the helper's JSON is passed through
|
||||
verbatim, refusal included. Without `--routectl-socket` (the flag's value set empty) the
|
||||
answer is `503 no route helper on this host`. The bytes are written by the socket-
|
||||
activated root helper (`deploy/dashboards-routectl.py`); this process opens no config
|
||||
file for writing, so `--allow-writes` on a box without the helper is a 503, not a write.
|
||||
* **The state** is a small pid->cpu-time cache so `processes.top_cpu` can answer a
|
||||
question /proc cannot (CPU needs two samples), bounded and cleared when stale, plus
|
||||
the short-TTL SMART result cache above.
|
||||
@@ -139,18 +159,27 @@ SCHEMA (GET /snapshot)
|
||||
top_cpu: [{pid, name, cpu_pct}]}
|
||||
haproxy {available: true|false|null, config: str, frontends: [{name, binds:
|
||||
[{addr, port}], default_backend: str|null, backends: [str]}],
|
||||
backends: [{name, servers: [{name, target, port}]}]}
|
||||
backends: [{name, servers: [{name, target, port,
|
||||
disabled: bool}]}]}
|
||||
# parsed from --haproxy-config; additive (phase 28) and absent entirely
|
||||
# with --no-proxy. `addr` is the bind's address as written (`:::22` ->
|
||||
# `::`), `port` null when a target names none (never 0), and the
|
||||
# frontend->backend routing is the names themselves: the dashboard
|
||||
# joins them, the agent reports only what the config says.
|
||||
# `disabled` is the route switch's read side (phase 33, agent 1.4.0):
|
||||
# true when the line carries the helper's `#off <name>` marker
|
||||
# (ROUTE_MARKER), false otherwise. The marker is a comment in both
|
||||
# configs, so a marked line is still a live line to haproxy/nginx until
|
||||
# the helper rewrites the value — the agent reports the state, the
|
||||
# dashboard draws it, and the kernel does not know markers.
|
||||
nginx {available: true|false|null, config: str, files: [str],
|
||||
servers: [{listen: [{addr, port}], server_names: [str], proxy_passes:
|
||||
[{location: str|null, scheme: str|null,
|
||||
target: {host, port}|null, variable: str|null}]}],
|
||||
upstreams: [{name, servers: [{host, port}]}],
|
||||
maps: [{variable, entries: [{key, target: {host, port}|null}]}]}
|
||||
target: {host, port}|null, variable: str|null,
|
||||
disabled: bool}]}],
|
||||
upstreams: [{name, servers: [{host, port, disabled: bool}]}],
|
||||
maps: [{variable, entries: [{key, target: {host, port}|null,
|
||||
disabled: bool}]}]}
|
||||
# parsed from --nginx-config plus the files its own `include` lines name
|
||||
# (one level, at most 8 files / 1 MB of included text; `files` is the
|
||||
# main config then every include actually merged); additive (phase 28)
|
||||
@@ -158,6 +187,10 @@ SCHEMA (GET /snapshot)
|
||||
# written (`[::]:8443` -> `"::"`, a bare `80` -> a null addr, never `*`),
|
||||
# `server_names` keeps the catch-all `_` verbatim, `location` is null for
|
||||
# a proxy_pass written in the server block itself (the stream form), and
|
||||
# `disabled` (phase 33, agent 1.4.0) is the line's `#off <name>` marker
|
||||
# read by the tokenizer, exactly as the haproxy section's: on a
|
||||
# `proxy_pass`, an `upstream` member line, or a `map` entry line. See
|
||||
# the haproxy note for what the flag does and does not mean.
|
||||
# a `proxy_pass $var` is served with `variable` (the `$` dropped) and
|
||||
# `target: null`: the map whose variable matches decides per connection,
|
||||
# so both facts travel unmerged and the dashboard joins them.
|
||||
@@ -213,7 +246,12 @@ other protection is `--cert-bundle`/AGENT_CERT_BUNDLE: one installed PEM (server
|
||||
key, the dashboard's root CA, `# BUNDLE-REV <n>` and `# REVOKED <serial>` lines) turns the
|
||||
server into HTTPS that answers every path but `/health` only to a client certificate signed
|
||||
by that root, and refuses one on the denylist on every path — the certificate is the auth, so
|
||||
no token is read on that path.
|
||||
no token is read on that path. The write path (1.4.0, the gateway) is `--allow-writes`
|
||||
(default off) plus `--routectl-socket` (default /run/dashboards-routectl.sock, the helper's
|
||||
socket; set the flag's value empty for the `503 no route helper on this host` arm): a
|
||||
`POST /route` is then answered certificate-first, then flag, then body, then one dial to
|
||||
that socket, and the helper's JSON is passed through verbatim — see the design rule above
|
||||
for the refusal sentences.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -237,8 +275,65 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
AGENT_VERSION = "1.3.0"
|
||||
AGENT_VERSION = "1.4.0"
|
||||
SCHEMA_VERSION = 1
|
||||
#: POST /route: one newline-delimited JSON request to the routectl helper, one line back.
|
||||
#: The budget covers the helper's own checker run (`nginx -t` / `haproxy -c -f`) plus reload.
|
||||
ROUTE_TIMEOUT_SECONDS = 30
|
||||
#: The largest body the write path accepts, checked before a byte is read off the wire.
|
||||
_ROUTE_MAX_BODY = 4096
|
||||
#: An answer longer than this is "unreadable" — the helper's status JSON never is.
|
||||
_ROUTE_MAX_ANSWER = 131072
|
||||
# The route switch's marker, one constant the parser and the write path both read (phase 33).
|
||||
# A comment in both haproxy and nginx, so `line, marker = split_marker(line)` on a route line
|
||||
# is the whole of the read side: a marked line is still a live line to the proxy until the
|
||||
# helper rewrites its value, and this is the state, not a behaviour the agent enforces.
|
||||
ROUTE_MARKER = "#off"
|
||||
|
||||
|
||||
def split_marker(line: str) -> tuple[str, str | None]:
|
||||
"""Split a config line into ``(line, marker)`` for the helper's ``#off <name>`` marker.
|
||||
|
||||
The marker is the **whole** trailing comment (``#...`` through end of line), so the
|
||||
grammar is one and the write path (task 01's ``routectl``) and the parser share it: a
|
||||
line is marked when its comment *is* ``#off <name>`` — a marker with nothing after the
|
||||
word, and nothing but the word, after it. Anything else in the comment is the operator's
|
||||
own note, not a switch, and the line is reported ``disabled: false``. A marked line stays
|
||||
in the document (the helper edits the value, never the line), which is what lets the
|
||||
dashboard tell "declared, off" from "never declared" (that split is the dashboard's
|
||||
``enabled`` field, phase 33 task 04).
|
||||
"""
|
||||
for i, ch in enumerate(line):
|
||||
if ch == "#":
|
||||
marker = line[i:]
|
||||
if marker == ROUTE_MARKER or marker.startswith(ROUTE_MARKER + " "):
|
||||
name = marker[len(ROUTE_MARKER):].strip()
|
||||
if name and " " not in name:
|
||||
return line[:i].rstrip(), name
|
||||
return line, None
|
||||
return line, None
|
||||
|
||||
|
||||
def marker_for(name: str) -> str:
|
||||
"""The full marker comment for one route name (the write path's spelling, pinned here so
|
||||
the parser and task 01's helper cannot drift). One name per marker: a marker is a whole
|
||||
comment and the name has no spaces, so the split above is exact."""
|
||||
return f"{ROUTE_MARKER} {name}"
|
||||
|
||||
|
||||
def config_line_index(text: str, name: str) -> int:
|
||||
"""The 1-based line number of the route line carrying ``name``'s marker, or ``-1``.
|
||||
|
||||
Task 01's helper needs the line to edit; the marker names the route, so the search is
|
||||
the marker itself, never the route's text (a target string appears in many lines and
|
||||
only one of them is the route). `-1` is "not found", which the helper maps to its
|
||||
`not-found` refusal.
|
||||
"""
|
||||
marker = marker_for(name)
|
||||
for i, line in enumerate(text.splitlines()):
|
||||
if split_marker(line)[1] == name and marker in line:
|
||||
return i + 1
|
||||
return -1
|
||||
PAGE_SIZE = 0 # resolved from sysconf at runtime
|
||||
|
||||
# Filesystems that are not storage. Mirrors the spirit of node_exporter's default
|
||||
@@ -2037,6 +2132,11 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
|
||||
if backend is not None and backend not in current_fe["backends"]:
|
||||
current_fe["backends"].append(backend)
|
||||
if current_be is not None and directive == "server" and len(words) > 2:
|
||||
# the route switch's read side (phase 33, agent 1.4.0): the marker is a trailing
|
||||
# comment, `#off <name>`, and a marked line is still a live line to haproxy until
|
||||
# the helper rewrites the value — the agent reports the state, the dashboard
|
||||
# draws it (`enabled: false`), and the kernel does not know markers.
|
||||
marked = split_marker(stripped)[1]
|
||||
target = _haproxy_addr_port(words[2])
|
||||
current_be["servers"].append({
|
||||
"name": words[1],
|
||||
@@ -2044,6 +2144,7 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
|
||||
# port — "absent is never 0", on the one field haproxy makes optional
|
||||
"target": words[2] if target is None else target[0],
|
||||
"port": None if target is None else target[1],
|
||||
"disabled": marked is not None,
|
||||
})
|
||||
return frontends, backends, recognised
|
||||
|
||||
@@ -2052,9 +2153,12 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
|
||||
"""Parse a haproxy config's **text** into the `haproxy` section's data. Pure, never raises.
|
||||
|
||||
`{frontends: [{name, binds: [{addr, port}], default_backend, backends}], backends:
|
||||
[{name, servers: [{name, target, port}]}], recognised: bool}` — the frontend→backend routing
|
||||
is the names themselves (`default_backend` plus every `use_backend`), so the dashboard joins
|
||||
them and this file interprets no rule.
|
||||
[{name, servers: [{name, target, port, disabled}]}], recognised: bool}` — the frontend→
|
||||
backend routing is the names themselves (`default_backend` plus every `use_backend`), so the
|
||||
dashboard joins them and this file interprets no rule. `disabled` is the line's `#off
|
||||
<name>` marker (phase 33, agent 1.4.0; `ROUTE_MARKER`, `split_marker`), `true` on a marked
|
||||
line, `false` on every other line — the write path (the helper) is the only writer, and a
|
||||
marker with anything but one name after it is the operator's comment, not a switch.
|
||||
|
||||
`recognised` answers the separate question "is this text a haproxy config at all" (does its
|
||||
top level name a section keyword?) and is deliberately **not** part of the served section:
|
||||
@@ -2070,7 +2174,7 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _nginx_tokens(text: str) -> list[tuple[str, str]]:
|
||||
"""Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", "")`.
|
||||
"""Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", comment)`.
|
||||
|
||||
nginx's grammar is words closed by `;` or opened by `{`, with `#` comments and quoted
|
||||
strings that may contain any of those three — the captured gateway config writes
|
||||
@@ -2083,16 +2187,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
|
||||
A `#` opens a comment only where a word would have started, which is where nginx's own
|
||||
lexer reads one: `proxy_pass http://host/#/app` keeps its fragment, and the
|
||||
`# include /etc/nginx/old.conf;` line an operator commented out contributes nothing.
|
||||
|
||||
The one value a token carries is a `;` (or `}`) token's **end-of-line comment**: a comment
|
||||
on the same line, after the statement that closed it, is attached to that token, and every
|
||||
other comment is dropped as before. That is the route switch's read side (phase 33):
|
||||
`server s1 10.1.1.9:443 #off s1` is a live line with a state, and the statement scanner is
|
||||
what hands the comment to the directive. A comment at the start of a line (a commented-out
|
||||
directive) belongs to no statement and is dropped, exactly as today.
|
||||
"""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
word: list[str] = []
|
||||
started = False # an empty quoted string is still a word: the `''` key of a map
|
||||
line_terminated = False # a `;` or `}` has been emitted on this line
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
char = text[i]
|
||||
if char == "#" and not started and (i == 0 or text[i - 1] in " \t\r\n"):
|
||||
while i < n and text[i] != "\n":
|
||||
i += 1
|
||||
end = i
|
||||
while end < n and text[end] != "\n":
|
||||
end += 1
|
||||
if line_terminated and tokens and tokens[-1][0] in ";}":
|
||||
tokens[-1] = (tokens[-1][0], text[i:end])
|
||||
i = end
|
||||
continue
|
||||
if char in "\"'":
|
||||
quote = char
|
||||
@@ -2116,12 +2232,15 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
|
||||
tokens.append(("w", "".join(word)))
|
||||
word, started = [], False
|
||||
tokens.append((char, ""))
|
||||
line_terminated = True
|
||||
i += 1
|
||||
continue
|
||||
if char.isspace():
|
||||
if started:
|
||||
tokens.append(("w", "".join(word)))
|
||||
word, started = [], False
|
||||
if char == "\n":
|
||||
line_terminated = False
|
||||
i += 1
|
||||
continue
|
||||
word.append(char)
|
||||
@@ -2132,15 +2251,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
|
||||
return tokens
|
||||
|
||||
|
||||
def _comment_off(comment: str) -> bool:
|
||||
"""Does this end-of-line comment carry the route switch's marker?
|
||||
|
||||
The grammar is `split_marker`'s — the one the write path (task 01's helper) shares —
|
||||
applied to a line whose only comment is the one the tokenizer attached: `#off <name>`
|
||||
with exactly one name after the word. The delegation is the point: the parser and the
|
||||
helper cannot read the same marker differently.
|
||||
"""
|
||||
return split_marker(f"line {comment}")[1] is not None
|
||||
|
||||
|
||||
def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
|
||||
"""The brace-block scanner: tokens in, nested statements out. Raises on an unbalanced file.
|
||||
|
||||
A statement is `("d", name, args)` for a directive or `("b", name, args, children)` for a
|
||||
block. That is the whole difference between the two config languages this agent reads:
|
||||
haproxy's structure is its indentation, nginx's is its braces, so nginx gets a scanner
|
||||
instead of a line loop — and with it the two shapes that make the language what it is, a
|
||||
`server { … location { … } … }` nested as deep as it goes and an `upstream` body whose
|
||||
`server` lines are *targets* while an `http` body's `server {` is a *block*.
|
||||
A statement is `("d", name, args, comment)` for a directive — `comment` is the end-of-line
|
||||
comment the tokenizer attached (the route switch's marker lives there, phase 33) or `""` —
|
||||
and `("b", name, args, children)` for a block. That is the whole difference between the two
|
||||
config languages this agent reads: haproxy's structure is its indentation, nginx's is its
|
||||
braces, so nginx gets a scanner instead of a line loop — and with it the two shapes that
|
||||
make the language what it is, a `server { … location { … } … }` nested as deep as it goes
|
||||
and an `upstream` body whose `server` lines are *targets* while an `http` body's `server {`
|
||||
is a *block*.
|
||||
|
||||
`ValueError` here means "nginx itself would refuse to start on this file": a stray brace or
|
||||
a directive that runs into the end of the file without its `;`. The caller turns that into
|
||||
@@ -2156,7 +2288,7 @@ def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
|
||||
continue
|
||||
if kind == ";":
|
||||
if words: # a bare `;` is whitespace to nginx
|
||||
containers[-1].append(("d", words[0], words[1:]))
|
||||
containers[-1].append(("d", words[0], words[1:], value))
|
||||
words = []
|
||||
continue
|
||||
if kind == "{":
|
||||
@@ -2271,6 +2403,10 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
|
||||
"""
|
||||
for statement in statements:
|
||||
kind, name, args = statement[0], statement[1], statement[2]
|
||||
# the end-of-line comment, if the tokenizer attached one: the route switch's marker
|
||||
# (phase 33) lives on the route line itself, and `_comment_off` is its read
|
||||
comment = statement[3] if len(statement) > 3 and isinstance(statement[3], str) else ""
|
||||
marked = _comment_off(comment)
|
||||
if kind == "d":
|
||||
if name == "include" and args:
|
||||
if root:
|
||||
@@ -2284,6 +2420,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
|
||||
# keeps its name and a null port rather than losing the line entirely
|
||||
"host": args[0] if found is None else found[0],
|
||||
"port": None if found is None else found[1],
|
||||
"disabled": marked,
|
||||
})
|
||||
continue
|
||||
if map_ctx is not None:
|
||||
@@ -2297,6 +2434,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
|
||||
# `upgrade` / `close` values are header words, and calling one a host
|
||||
# would be this section inventing a fact to fill a field
|
||||
"target": None if found is None else {"host": found[0], "port": found[1]},
|
||||
"disabled": marked,
|
||||
})
|
||||
continue
|
||||
if server is not None:
|
||||
@@ -2309,7 +2447,9 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
|
||||
# "any domain" is what the page says about it, not what the agent decides
|
||||
server["server_names"].extend(args)
|
||||
elif name == "proxy_pass" and args:
|
||||
server["proxy_passes"].append(_nginx_proxy_pass(args[0], location))
|
||||
entry = _nginx_proxy_pass(args[0], location)
|
||||
entry["disabled"] = marked
|
||||
server["proxy_passes"].append(entry)
|
||||
continue
|
||||
children = cast("_NginxStatements", statement[3])
|
||||
if name == "upstream" and args:
|
||||
@@ -2367,9 +2507,14 @@ def parse_nginx_config(main_text: str,
|
||||
"""Parse an nginx main config plus the documents its includes name. Pure, never raises.
|
||||
|
||||
`{servers: [{listen: [{addr, port}], server_names: [str], proxy_passes: [{location, scheme,
|
||||
target, variable}]}], upstreams: [{name, servers: [{host, port}]}],
|
||||
maps: [{variable, entries: [{key, target}]}], files: [str], includes: [str], unparsed:
|
||||
[str], recognised: bool, error: str|null, resolver: str|null}`.
|
||||
target, variable, disabled}]}], upstreams: [{name, servers: [{host, port, disabled}]}],
|
||||
maps: [{variable, entries: [{key, target, disabled}]}], files: [str], includes: [str],
|
||||
unparsed: [str], recognised: bool, error: str|null, resolver: str|null}`.
|
||||
|
||||
`disabled` on a `proxy_pass`, an `upstream` member line, or a `map` entry line is the
|
||||
route switch's read side (phase 33, agent 1.4.0): `true` when that line carries the
|
||||
helper's `#off <name>` marker, `false` otherwise — one grammar (`split_marker`), the same
|
||||
one the haproxy section uses, and the same one the write path (task 01's helper) shares.
|
||||
|
||||
Four of those keys are the ones `Collector.nginx` does not serve, the way
|
||||
`parse_haproxy_config`'s `recognised` is not: `error` is why the main text could not be
|
||||
@@ -2979,6 +3124,12 @@ STATUS_PAGE = """<!doctype html>
|
||||
</table>
|
||||
<p><a href="/snapshot">/snapshot</a> · <a href="/info">/info</a> ·
|
||||
<a href="/health">/health</a> · <a href="/schema">/schema</a></p>
|
||||
<p style=color:#8b93a7>POST /route — the one write (switch a route's #off marker; 1.4.0).
|
||||
Refusals, in order: 405 no such write path · 403 writes require a certificate (token
|
||||
mode) · 403 client certificate required · 403 client certificate revoked · 403 writes
|
||||
are not enabled on this agent · 422 body refusals · 503 no route helper on this
|
||||
host · 502 helper unreachable / timed out / unreadable answer. The bytes are written
|
||||
by the socket-activated root helper, never by this process.</p>
|
||||
"""
|
||||
|
||||
|
||||
@@ -2987,6 +3138,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
collector: Collector
|
||||
token: str | None = None
|
||||
bundle: TlsBundle | None = None
|
||||
allow_writes: bool = False
|
||||
routectl_socket: str | None = None
|
||||
|
||||
def do_GET(self) -> None: # the BaseHTTPRequestHandler API name — keep it
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
@@ -3032,6 +3185,102 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send(404, {"ok": False, "detail": f"no such path: {path}"})
|
||||
|
||||
def do_POST(self) -> None: # the BaseHTTPRequestHandler API name — keep it
|
||||
# The one write (1.4.0). The order is the design, and each refusal is its own
|
||||
# sentence: the certificate (the handshake is the identity — the token arm is
|
||||
# refused outright, the denylist is not a read-side rule), then the operator's
|
||||
# flag, then the body (a refused body never reaches the peer), then the helper's
|
||||
# socket. Nothing in this method writes a file; the bytes are the helper's.
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
if path != "/route":
|
||||
self._send(405, {"ok": False, "detail": "no such write path"})
|
||||
return
|
||||
if self.bundle is None:
|
||||
self._send(403, {"ok": False, "detail": "writes require a certificate"})
|
||||
return
|
||||
if not self._certificate_allows(self.bundle, path):
|
||||
return
|
||||
if not self.allow_writes:
|
||||
self._send(403, {"ok": False, "detail": "writes are not enabled on this agent"})
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length < 0 or length > _ROUTE_MAX_BODY:
|
||||
self._send(422, {"ok": False,
|
||||
"detail": "body longer than the write path accepts"})
|
||||
return
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send(422, {"ok": False, "detail": "body is not a JSON object"})
|
||||
return
|
||||
if not isinstance(body, dict):
|
||||
self._send(422, {"ok": False, "detail": "body is not a JSON object"})
|
||||
return
|
||||
if not set(body) <= {"id", "action", "dry_run"}:
|
||||
self._send(422, {"ok": False, "detail": "body carries a key the write path does not take"})
|
||||
return
|
||||
action = body.get("action")
|
||||
if action not in ("disable", "enable"):
|
||||
self._send(422, {"ok": False, "detail": "action must be disable or enable"})
|
||||
return
|
||||
rid = body.get("id")
|
||||
if not isinstance(rid, str) or not rid or len(rid) > 256:
|
||||
self._send(422, {"ok": False,
|
||||
"detail": "id must be a string of at most 256 characters"})
|
||||
return
|
||||
if self.routectl_socket is None:
|
||||
self._send(503, {"ok": False, "detail": "no route helper on this host"})
|
||||
return
|
||||
self._route_peer({"id": rid, "action": action,
|
||||
"dry_run": bool(body.get("dry_run", False))})
|
||||
|
||||
def _route_peer(self, payload: dict[str, Any]) -> None:
|
||||
"""Dial the routectl helper once, forward `payload`, pass the one answer through.
|
||||
|
||||
One connection, one request, one answer: a retry of a write is a second reload,
|
||||
so every failure is a 502 and never a second dial. The helper's JSON travels
|
||||
verbatim — a refusal it answers stays a refusal, and the agent invents no
|
||||
success of its own. The log line prints the path and the status only: the
|
||||
`id` is a hostname, and this process's log lines stay out of its body.
|
||||
"""
|
||||
buf = b""
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(ROUTE_TIMEOUT_SECONDS)
|
||||
sock.connect(self.routectl_socket)
|
||||
sock.sendall(json.dumps(payload).encode("utf-8") + b"\n")
|
||||
while not buf.endswith(b"\n"):
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if len(buf) > _ROUTE_MAX_ANSWER:
|
||||
self._send(502, {"ok": False,
|
||||
"detail": "route helper answered something unreadable"})
|
||||
return
|
||||
except TimeoutError:
|
||||
self._send(502, {"ok": False, "detail": "route helper timed out"})
|
||||
return
|
||||
except OSError as exc:
|
||||
word = socket.errorname.get(exc.errno, str(exc.errno))
|
||||
self._send(502, {"ok": False, "detail": f"route helper: {word}"})
|
||||
return
|
||||
finally:
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
try:
|
||||
answer = json.loads(buf.decode("utf-8").strip())
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send(502, {"ok": False,
|
||||
"detail": "route helper answered something unreadable"})
|
||||
return
|
||||
self._send(200, answer)
|
||||
|
||||
def _certificate_allows(self, bundle: TlsBundle, path: str) -> bool:
|
||||
"""Does this connection's client certificate authorise `path`? Sends the refusal if not.
|
||||
|
||||
@@ -3094,17 +3343,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def serve(host: str, port: int, collector: Collector, token: str | None,
|
||||
bundle: TlsBundle | None = None) -> None:
|
||||
bundle: TlsBundle | None = None, *, allow_writes: bool = False,
|
||||
routectl_socket: str | None = None) -> None:
|
||||
"""Serve forever on host:port — plain HTTP with an optional token, or HTTPS with a bundle.
|
||||
|
||||
All three class attributes are assigned on every call: `Handler` is process-wide state, so a
|
||||
server started without a bundle must not inherit one from a server that had it (that would
|
||||
answer a plain-HTTP socket with 403 on every path, or worse, accept a client certificate the
|
||||
operator revoked).
|
||||
All five class attributes are assigned on every call: `Handler` is process-wide state, so a
|
||||
server started without a bundle (or without the write flag) must not inherit one from a
|
||||
server that had it (that would answer a plain-HTTP socket with 403 on every path, or
|
||||
worse, accept a client certificate the operator revoked — or answer a write to a helper
|
||||
this process was never told about).
|
||||
"""
|
||||
Handler.collector = collector
|
||||
Handler.token = token
|
||||
Handler.bundle = bundle
|
||||
Handler.allow_writes = allow_writes
|
||||
Handler.routectl_socket = routectl_socket
|
||||
httpd = ThreadingHTTPServer((host, port), Handler)
|
||||
if bundle is not None:
|
||||
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
|
||||
@@ -3178,6 +3431,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
f"documented degradation (available=false, one error entry), "
|
||||
f"not a failure")
|
||||
parser.add_argument("--once", action="store_true", help="print one snapshot as JSON and exit")
|
||||
parser.add_argument("--allow-writes", action="store_true",
|
||||
help="answer POST /route on this agent (the fleet's one narrow write: "
|
||||
"switch a route's #off marker). The gates are the client "
|
||||
"certificate and this flag, in that order; the bytes are written "
|
||||
"by the socket-activated root helper, never by this process")
|
||||
parser.add_argument("--routectl-socket",
|
||||
default=os.environ.get("AGENT_ROUTECTL_SOCKET",
|
||||
"/run/dashboards-routectl.sock"),
|
||||
help="the routectl helper's socket that POST /route dials once per "
|
||||
"request; set the value empty for the "
|
||||
"`503 no route helper on this host` arm")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
collector = Collector(args.root, top_processes=args.top, net_all=args.net_all,
|
||||
@@ -3203,7 +3467,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"metrics are sensitive); pass --token, --token-file, --cert-bundle or set "
|
||||
"AGENT_TOKEN / AGENT_CERT_BUNDLE", flush=True)
|
||||
return 2
|
||||
serve(args.host, args.port, collector, token, bundle)
|
||||
serve(args.host, args.port, collector, token, bundle,
|
||||
allow_writes=args.allow_writes,
|
||||
routectl_socket=args.routectl_socket or None)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user