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:
2026-11-01 11:50:51 -05:00
parent 0271387053
commit 86c8bf4992
91 changed files with 7568 additions and 519 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
response = client.post("/api/auth/login", json={"password": password, **payload})
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
body: dict[str, Any] = response.json()
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to
# the one write endpoint, and a token the helper did not attach would 403 every one of them.
# On the client, not per request: the existing HTTP assertions in test_api.py post to the
# upstream write endpoints, and a token the helper did not attach would 403 every one of them.
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
return body
+62 -1
View File
@@ -34,6 +34,14 @@ def pytest_addoption(parser: pytest.Parser) -> None:
help="run tests marked `store_perf`: the phase-14 timing measurements against the "
"seeded production-shaped store (they skip otherwise — they cost minutes, not ms)",
)
parser.addoption(
"--allow-live-writes",
action="store_true",
default=False,
help="allow the one opt-in live write test (phase 33's route round-trip on "
"haproxy:5201->web.reeselink.com:5201) to switch its named route; without it "
"the test skips and the default live suite stays read-only",
)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
@@ -257,7 +265,8 @@ GATEWAY_TREE = {
"proc/net/tcp6": "gateway_proc_net_tcp6.txt",
}
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent")
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent",
"marked", "hand_edited")
@pytest.fixture
@@ -287,6 +296,16 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
agent's own **not found** line, the other half of the page untouched
* `sockets_silent` — neither `/proc` table readable: `available: null`, so every live count
must come back `None` and never `0`
* `marked` / `hand_edited` — the same captured tree with the route switch's three edits
applied (phase 33 task 04): `marked` is the helper's work — one haproxy `server` line,
one vhost `proxy_pass` line and one stream `map` entry (value rewritten to the sink
address) each carrying the agent's `#off <name>` trailing comment (the whole comment, one
name, nothing else — `split_marker`'s grammar), plus the helper's sink `server` block in
a `stream.d` file; `hand_edited` is the same three shapes with the markers removed — the
`server` line deleted, the `proxy_pass` line plain, the map entry's value deleted and
no sink file. Both are the **captured** config plus an edit, never a toy file: the
marker-awareness tests need the fleet's real backends and map, and the suppression has to
be marker-caused, not shape-caused.
"""
def build(mode: str = "full") -> dict[str, Any]:
if mode not in GATEWAY_MODES:
@@ -307,6 +326,48 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
if mode == "sockets_silent":
(root / "proc/net/tcp").unlink()
(root / "proc/net/tcp6").unlink()
if mode in ("marked", "hand_edited"):
# The one haproxy line the switch turns off: the helper marks the `server` line
# itself (the agent's `split_marker` reads the trailing comment; the name is one
# word and the whole comment) and the line stays in the document. `hand_edited`
# is the same shape an operator left by deleting the line.
haproxy_cfg = root / "etc/haproxy/haproxy.cfg"
text = haproxy_cfg.read_text()
line = " server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s"
assert line in text, "the capture's gitea server line moved; update the injection"
if mode == "marked":
haproxy_cfg.write_text(text.replace(line, f"{line} #off gitea"))
else:
haproxy_cfg.write_text(text.replace(line + "\n", ""))
# The one vhost line: the marker goes after the `;` (the tokenizer attaches the
# end-of-line comment to the statement that closed it). `hand_edited` keeps the
# line — removing a marker leaves the route declared and on.
vhost = root / "etc/nginx/conf.d/http.conf"
text = vhost.read_text()
line = " proxy_pass http://nextcloud.reeselink.com:11000;"
assert line in text, "the capture's nextcloud vhost line moved; update the injection"
if mode == "marked":
vhost.write_text(text.replace(line, f"{line} #off nextcloud-11000"))
# The one map entry: the helper rewrites the value to the sink address and marks
# the line; `hand_edited` is the value deleted and nothing else left behind.
stream = root / "etc/nginx/stream.d/stream.conf"
text = stream.read_text()
line = (" brain.reeseapps.com "
"reeseapps-brain-of-reese.reeselink.com:443;")
assert line in text, "the capture's brain map entry moved; update the injection"
if mode == "marked":
stream.write_text(text.replace(line, " brain.reeseapps.com "
"127.0.0.1:44450; #off brain.reeseapps.com"))
else:
stream.write_text(text.replace(line, " brain.reeseapps.com ;"))
if mode == "marked":
# The helper's sink file, created on first need by the write path: a stream
# `server` that listens on loopback, names no `server_name` and proxies nowhere
# (its whole job is to answer a switched-off route). It is the block, not the
# port, that `sink_endpoint()` recognises, and the glob include picks it up.
(root / "etc/nginx/stream.d/00-dashboards-sink.conf").write_text(
"# managed by dashboards-routectl — do not edit\n"
"server {\n listen 127.0.0.1:44450;\n}\n")
errors: list[str] = []
collector = host_agent_module.Collector(root)
+593 -41
View File
@@ -27,6 +27,7 @@ import threading
import time
import urllib.error
import urllib.request
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from http.server import ThreadingHTTPServer
from pathlib import Path
@@ -838,8 +839,10 @@ def test_the_captured_haproxy_shape_parses_into_frontends_and_backends() -> None
[{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)]
assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [
"svc-2222", "svc-2000", "svc-5201", "svc-8080", "svc-25565", "svc-25566"]
# no line carries the helper's marker, so none is off (the flag is present, not inferred)
assert [backend["servers"] for backend in parsed["backends"]] == [
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port}]
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port,
"disabled": False}]
for port in (2222, 2000, 5201, 8080, 25565, 25566)]
# routing is the names themselves, and the config joins: every frontend's default_backend is
# one of the backends listed beside it (phase 29 draws its pipes from exactly this)
@@ -865,9 +868,10 @@ def test_the_recorded_gateway_haproxy_config_parses_too() -> None:
"testcraft-25565", "nimcraft-25566"]
assert [(backend["name"], backend["servers"]) for backend in parsed["backends"]][:2] == [
("gitea-2222", [{"name": "gitea", "target": "reeseapps-gitea.reeselink.com",
"port": 2222}]),
("sec-gitea-2000", [{"name": "sec-gitea", "target": "stackexpected-gitea.reeselink.com",
"port": 2222}]),
"port": 2222, "disabled": False}]),
("sec-gitea-2000", [{"name": "sec-gitea",
"target": "stackexpected-gitea.reeselink.com",
"port": 2222, "disabled": False}]),
]
# every frontend's target exists beside it — the join phase 29 draws its pipes from
names = {backend["name"] for backend in parsed["backends"]}
@@ -912,9 +916,10 @@ def test_a_server_that_names_no_port_keeps_null_and_unknown_lines_are_ignored()
" http-request deny deny_status 403\n"
)
assert parsed["backends"][0]["servers"] == [
{"name": "withport", "target": "10.1.0.9", "port": 8080},
{"name": "noport", "target": "10.1.0.10", "port": None},
{"name": "servicename", "target": "host.example.com:http", "port": None},
{"name": "withport", "target": "10.1.0.9", "port": 8080, "disabled": False},
{"name": "noport", "target": "10.1.0.10", "port": None, "disabled": False},
{"name": "servicename", "target": "host.example.com:http", "port": None,
"disabled": False},
]
# a `backend` body never grows frontend fields, however many directives it holds
assert "binds" not in parsed["backends"][0]
@@ -933,7 +938,7 @@ def test_a_listen_section_is_reported_as_the_pair_haproxy_says_it_is() -> None:
"default_backend": "stats", "backends": ["stats"]}]
assert parsed["backends"] == [{"name": "stats",
"servers": [{"name": "own", "target": "127.0.0.1",
"port": 8405}]}]
"port": 8405, "disabled": False}]}]
def test_a_backend_computed_per_request_contributes_no_name() -> None:
@@ -1249,27 +1254,30 @@ def test_the_captured_nginx_shape_parses_into_servers_maps_and_targets() -> None
assert [server["proxy_passes"] for server in parsed["servers"]] == [
[],
# the stream form: no `location` exists, and the target is a variable, not an address
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend"}],
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}],
[{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "nextcloud.internal.test", "port": 11000}}],
"target": {"host": "nextcloud.internal.test", "port": 11000}, "disabled": False}],
[{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "127.0.0.1", "port": 8081}}],
"target": {"host": "127.0.0.1", "port": 8081}, "disabled": False}],
]
maps = by_variable(parsed["maps"])
assert set(maps) == {"connection_upgrade", "targetBackend"}
# the variable name is spelled one way on both sides of the join, and the `$` is nginx's
# syntax rather than part of the name — so it is dropped here once, not in the page
assert maps["targetBackend"]["entries"] == [
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443}},
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443},
"disabled": False},
{"key": "brain.example.com", "target": {"host": "brain-of-reese.internal.test",
"port": 443}},
"port": 443}, "disabled": False},
]
# …and the `proxy_pass $targetBackend` above still carries `target: null`: the map decides
# per connection, so merging it here would be a number the wire never published
assert parsed["servers"][1]["proxy_passes"][0]["target"] is None
# `$connection_upgrade`'s values are header words, not hosts: keys reported, targets null
assert maps["connection_upgrade"]["entries"] == [
{"key": "default", "target": None}, {"key": "", "target": None}]
{"key": "default", "target": None, "disabled": False},
{"key": "", "target": None, "disabled": False}]
# the include lines, as written, in document order — including the one file this tree has no
# opinion about because it is not a config (`mime.types`)
assert parsed["includes"] == ["/etc/nginx/stream.d/*.conf", "/etc/nginx/mime.types",
@@ -1325,15 +1333,18 @@ def test_a_proxy_pass_reports_the_address_the_variable_or_neither() -> None:
assert parsed["servers"][0]["proxy_passes"] == [
# the trailing path is dropped the way haproxy's `check inter 5s` is: it is not a place
{"location": "/a/", "scheme": "http", "target": {"host": "h1.test", "port": 8080},
"variable": None},
"variable": None, "disabled": False},
# an `upstream` name is a host with no port — the name is the fact, the port is not
{"location": "/b/", "scheme": "http", "target": {"host": "pool_name", "port": None},
"variable": None},
{"location": "/c/", "scheme": None, "target": None, "variable": "backend"},
"variable": None, "disabled": False},
{"location": "/c/", "scheme": None, "target": None, "variable": "backend",
"disabled": False},
# a unix socket is neither a host nor a variable: the line is reported with nulls
{"location": "/d/", "scheme": None, "target": None, "variable": None},
{"location": "/d/", "scheme": None, "target": None, "variable": None,
"disabled": False},
# a colon that is not a port either: the scheme is a fact, the host would be a guess
{"location": "/e/", "scheme": "http", "target": None, "variable": None},
{"location": "/e/", "scheme": "http", "target": None, "variable": None,
"disabled": False},
]
@@ -1371,9 +1382,9 @@ def test_an_upstream_block_keeps_its_server_lines_as_targets() -> None:
"http { server { listen 80; } }"
)
assert parsed["upstreams"] == [{"name": "pool_name", "servers": [
{"host": "10.1.0.9", "port": 8080},
{"host": "backend.test", "port": None},
{"host": "::1", "port": 8080},
{"host": "10.1.0.9", "port": 8080, "disabled": False},
{"host": "backend.test", "port": None, "disabled": False},
{"host": "::1", "port": 8080, "disabled": False},
]}]
# an upstream's `server` lines are not server blocks, however many of them there are
assert len(parsed["servers"]) == 1
@@ -2179,8 +2190,11 @@ def agent_server():
host_agent.Handler.collector = host_agent.Collector("/")
host_agent.Handler.token = token
# Cleared on purpose: `Handler` is process-wide state, so a token-mode server started
# after a TLS test in this file must not inherit that test's bundle.
# after a TLS test in this file must not inherit that test's bundle — or the write
# path's gate, which the bundle check is the first line of.
host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
@@ -2253,15 +2267,6 @@ def test_token_is_required_when_configured_but_health_stays_open(agent_server) -
assert _get(base + "/health")[0] == 200
def test_agent_has_no_write_path(agent_server) -> None:
"""No do_POST/do_PUT exists: read-only is structural, not a policy toggle."""
base, _port = agent_server()
request = urllib.request.Request(base + "/snapshot", data=b"{}", method="POST")
with pytest.raises(urllib.error.HTTPError) as excinfo:
urllib.request.urlopen(request, timeout=5)
assert excinfo.value.code in (405, 501)
def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None:
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
monkeypatch.delenv("AGENT_TOKEN", raising=False)
@@ -2452,6 +2457,10 @@ def tls_agent_server():
host_agent.Handler.collector = host_agent.Collector("/", smart=False)
host_agent.Handler.token = token
host_agent.Handler.bundle = bundle
# Cleared on purpose, like `bundle` above: a writes-off server started after a
# `POST /route` test must not inherit that test's write gate or helper socket.
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
servers.append(httpd)
@@ -2463,6 +2472,8 @@ def tls_agent_server():
httpd.shutdown()
httpd.server_close()
host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
def _json_or_raw(body: bytes) -> dict:
@@ -2757,14 +2768,19 @@ def test_a_bundle_allows_the_off_loopback_bind_without_a_token(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The client certificate *is* the protection, so the exit-2 refusal does not apply."""
served: list[tuple] = []
seen_kwargs: list[dict] = []
monkeypatch.setattr(host_agent, "serve",
lambda *args: served.append(args))
lambda *args, **kwargs: served.append(args) or seen_kwargs.append(kwargs))
path = write_bundle(tmp_path, tls_fleet.bundle)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0
assert served, "a bundle-configured agent must start"
host_arg, _port, _collector, token_arg, bundle_arg = served[0]
assert (host_arg, token_arg) == ("0.0.0.0", None)
assert isinstance(bundle_arg, host_agent.TlsBundle)
# 1.4.0's defaults: the write path is off, and the helper's home is the standard socket
# path unless the flag names another one (an empty value is the 503 arm: no helper here).
assert seen_kwargs[0]["allow_writes"] is False
assert seen_kwargs[0]["routectl_socket"] == "/run/dashboards-routectl.sock"
@pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]])
@@ -2819,22 +2835,157 @@ def test_the_schema_names_the_three_proxy_sections() -> None:
assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc
def test_the_agent_version_is_1_3_0_and_the_schema_names_the_resolver_section() -> None:
"""1.3.0 is the version that resolves the names the configs declare.
def test_the_agent_version_is_1_4_0_and_the_schema_documents_the_route_marker() -> None:
"""1.4.0 is the version that reads the route switch's marker off the route line.
The bump is how the fleet tells an agent that publishes `resolver` from one that does not —
`--status`, `/health` and this test all read the same constant, and `sources/gateway.py`'s
"the agent is too old" arm keys on the sections the version belongs to. The SCHEMA block is
`GET /schema`'s body, so it names the section, its three-way `available`, and `--no-resolve`,
the flag that removes it in silence.
The bump is how the fleet tells an agent that publishes `disabled` from one that does not —
`--status`, `/health` and this test all read the same constant. The SCHEMA block is
`GET /schema`'s body, so it documents the flag on both config sections and the marker's
spelling, and `--no-resolve` stays named: the flag that removes the resolver section in
silence.
"""
assert host_agent.AGENT_VERSION == "1.3.0"
assert host_agent.AGENT_VERSION == "1.4.0"
doc = host_agent.__doc__ or ""
assert schema_documents("resolver"), "resolver is not documented in the SCHEMA block"
block = doc.split(" resolver ", 1)[-1]
assert "available" in block, "the resolver block does not document its three-way answer"
assert "--no-resolve" in doc, "the flag that removes the section is undocumented"
assert "libc" in block, "how the addresses were obtained is not published in the schema"
assert "disabled" in doc, "the marker read is not documented in the SCHEMA block"
assert "#off" in doc, "the marker's spelling is not documented in the schema"
def test_the_marker_grammar_is_one_constant_shared_by_writer_and_reader() -> None:
"""`split_marker` is the read side of the switch and `marker_for` the write side; both
spell the same grammar, which is the whole of the read side (phase 33 task 04).
A marked line keeps its text and gains a name; anything that is not exactly `#off <name>`
— an extra word, a bare `#off`, or the operator's own comment — is not a switch, and a line
without a comment is a line.
"""
assert host_agent.ROUTE_MARKER == "#off"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1")
assert line == "server s1 10.1.1.9:443" and name == "s1"
# trailing whitespace after the name is the operator's formatting, not part of the name
assert host_agent.split_marker("server s1 10.1.1.9:443 #off s1 ") == (
"server s1 10.1.1.9:443", "s1")
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off")
assert name is None, "a bare #off with no name is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1 extra")
assert name is None, "two words after the marker is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 # off s1")
assert name is None, "a comment that merely mentions the word is not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443")
assert name is None
# the write side spells what the read side parses: the round trip is exact
for name_ in ("s1", "pool:8080", "web.example.com"):
marked, marker = host_agent.split_marker(f"line {host_agent.marker_for(name_)}")
assert marker == name_ and marked == "line"
def test_config_line_index_finds_the_line_the_marker_names() -> None:
"""The helper edits by line number, and the line is found by the marker, never the route's
text (a target string appears in many lines; the marker names exactly one of them)."""
text = "backend be\n server s1 10.1.1.9:443\n server s2 10.1.1.10:443 #off s1\n"
assert host_agent.config_line_index(text, "s1") == 3
assert host_agent.config_line_index(text, "s2") == -1
# a commented-out line is not a route: its marker is part of the comment, not a switch
text = "# server s3 10.1.1.11:443 #off s3\n"
assert host_agent.config_line_index(text, "s3") == -1
def test_the_haproxy_parser_reports_a_marked_server_line_disabled() -> None:
"""The read side of the switch: a `server` line carrying the helper's marker is
`disabled: true` and every other line `disabled: false` — and the marked line is still a
line: its target is parsed and its text kept, because the helper rewrites the value, not
the line, and a parser that dropped it would lose the route the switch is about."""
parsed = host_agent.parse_haproxy_config(
"backend be\n"
" server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n"
" server note 10.1.1.11:443 # a comment that is not the switch\n"
)
assert parsed["backends"][0]["servers"] == [
{"name": "on", "target": "10.1.1.9", "port": 443, "disabled": False},
{"name": "off", "target": "10.1.1.10", "port": 443, "disabled": True},
{"name": "note", "target": "10.1.1.11", "port": 443, "disabled": False},
]
def test_the_nginx_parser_reports_a_marked_line_disabled_in_all_three_shapes() -> None:
"""One marker, three route lines: a `proxy_pass`, an `upstream` member, a `map` entry.
The stream form is the fleet's own: a `proxy_pass $var` with no location, a `map` whose
entries are the SNI fan-out, and an `upstream` the http vhosts name. Each line's marker is
its own line's `disabled`, and an unmarked line beside a marked one is still on."""
parsed = host_agent.parse_nginx_config(
"stream {\n"
" server { listen 443; proxy_pass $targetBackend; }\n"
" server { listen 127.0.0.1:44450; return 502; }\n"
" map $ssl_preread_server_name $targetBackend {\n"
" default 10.1.0.1:8008;\n"
" on.example.com 10.1.0.1:8008;\n"
" off.example.com 127.0.0.1:44450; #off off.example.com\n"
" }\n"
"}"
"http {\n"
" upstream pool {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" server 10.1.0.10:8080;\n"
" }\n"
" server { listen 80; location / { proxy_pass http://pool; } }\n"
"}"
)
assert parsed["servers"][0]["proxy_passes"] == [
{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}]
assert parsed["servers"][1]["proxy_passes"] == [], "a `return` block proxies nowhere"
entries = parsed["maps"][0]["entries"]
assert [entry["key"] for entry in entries] == ["default", "on.example.com", "off.example.com"]
assert [entry["disabled"] for entry in entries] == [False, False, True]
assert entries[2]["target"] == {"host": "127.0.0.1", "port": 44450}, \
"the marked entry keeps its value: the state is the flag, not a rewritten line"
assert parsed["upstreams"] == [{"name": "pool", "servers": [
{"host": "10.1.0.9", "port": 8080, "disabled": True},
{"host": "10.1.0.10", "port": 8080, "disabled": False},
]}]
def test_the_nginx_section_exposes_the_marker_flag_on_the_wire(tmp_path: Path) -> None:
"""The section the dashboard reads is where the flag must land: a marked line in the file
is a `disabled: true` row in the `haproxy` and `nginx` sections, and an unmarked config
reports `false`, never a missing key (the dashboard's `enabled: false` is the flag's
absence of absence, and a row that could not answer the question would read as on)."""
write_file(tmp_path / "root", "opt/haproxy/etc/haproxy.cfg",
"backend be\n server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n")
write_file(tmp_path / "root", "opt/nginx/conf/nginx.conf",
"stream {\n"
" server {\n"
" listen 443;\n"
" proxy_pass $v; #off v\n"
" }\n"
" map $x $v {\n"
" default 10.1.0.1:8008;\n"
" a.test 10.1.0.1:8008;\n"
" }\n"
"}\n"
"http {\n"
" upstream p {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" }\n"
"}\n")
errors: list[str] = []
collector = host_agent.Collector(
str(tmp_path / "root"), haproxy_config="/opt/haproxy/etc/haproxy.cfg",
nginx_config="/opt/nginx/conf/nginx.conf")
haproxy = collector.haproxy(errors)
assert [row["disabled"] for row in haproxy["backends"][0]["servers"]] == [False, True]
nginx = collector.nginx(errors)
assert nginx["servers"][0]["proxy_passes"][0]["disabled"] is True
assert [row["disabled"] for row in nginx["upstreams"][0]["servers"]] == [True]
assert [row["disabled"] for row in nginx["maps"][0]["entries"]] == [False, False]
assert errors == []
# ---- the recorded gateway: the captures themselves (phase 28 task 04) -------------------- #
@@ -3732,3 +3883,404 @@ def test_the_target_list_is_the_two_sections_and_survives_a_section_that_is_not_
("vhost.test", 443), ("map.test", 443)]
assert host_agent.target_names(None, None) == []
assert host_agent.target_names({"backends": "nonsense"}, {"maps": [7]}) == []
# ---- POST /route (the one write, agent 1.4.0) -------------------------------- #
class _Peer:
"""A routectl stub: an AF_UNIX socket that counts dials, reads one request line and
answers one line. `hang` holds the connection open without answering, which is what
makes the agent's 30 s budget (patched down in the test) fire. A dial it never sees
is a refusal that happened before the socket, so the counters are the pins."""
def __init__(self, tmp_path: Path, reply: str | None = None, *, hang: bool = False) -> None:
self.path = str(tmp_path / "peer.sock")
self.reply = reply
self.hang = hang
self.requests: list[dict[str, Any]] = []
self.connections = 0
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server.bind(self.path)
self._server.listen(8)
def serve(self, window: float) -> None:
deadline = time.monotonic() + window
self._server.settimeout(0.05)
while time.monotonic() < deadline:
try:
conn, _addr = self._server.accept()
except TimeoutError:
continue
except OSError:
return # stop() closed the server; the window is over
self.connections += 1
data = b""
try:
while b"\n" not in data:
chunk = conn.recv(65536)
if not chunk:
break
data += chunk
except OSError:
pass
if data:
self.requests.append(json.loads(data.decode()))
if self.hang:
continue # never answer; the agent's own timeout fires
if self.reply is not None:
with suppress(OSError):
conn.sendall(self.reply.encode())
conn.close()
def stop(self) -> None:
self._server.close()
def _start_peer(tmp_path: Path, **kwargs: Any) -> _Peer:
peer = _Peer(tmp_path, **kwargs)
threading.Thread(target=peer.serve, args=(6.0,), daemon=True).start()
return peer
def _https_post(url: str, context: ssl.SSLContext, body: bytes) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, method="POST")
try:
with urllib.request.urlopen(request, timeout=15, context=context) as response:
return response.status, _json_or_raw(response.read())
except urllib.error.HTTPError as error:
return error.code, _json_or_raw(error.read())
def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, headers=headers or {}, method="POST")
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def _route_body() -> bytes:
return json.dumps({"id": "nginx:443->brain.test:443", "action": "disable",
"dry_run": True}).encode()
def test_the_agent_is_1_4_0_and_the_write_path_is_a_documented_flag() -> None:
"""The version moves with the write; the two flags are on the command line, not in
config, and the status page and the docstring say the write exists and is gated."""
assert host_agent.AGENT_VERSION == "1.4.0"
assert host_agent.ROUTE_TIMEOUT_SECONDS == 30
help_text = subprocess.run([sys.executable, str(AGENT_PATH), "--help"],
capture_output=True, text=True, timeout=30).stdout
for flag in ("--allow-writes", "--routectl-socket", "--once"):
assert flag in help_text, flag
assert "POST /route" in (host_agent.__doc__ or "")
def test_a_post_without_a_client_certificate_is_refused(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The token is not a write credential: a probe that holds only the CA (the same
identity `install-agent.sh --status` uses) gets the certificate refusal, on a POST too."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
status, body = _https_post(base + "/route", client_context(tls_fleet.root_cert), _route_body())
assert status == 403
assert body["detail"] == "client certificate required"
def test_a_revoked_serial_is_refused_on_the_write_path_too(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A serial on the denylist is refused on every path, including the write — the
revoke in `/certs` bites a `POST /route` no later than it bites a `GET`."""
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
base = tls_agent_server(load_bundle(tmp_path, text))
status, body = _https_post(base + "/route",
client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert),
str(tls_fleet.stolen.key)),
_route_body())
assert status == 403
assert body["detail"] == "client certificate revoked"
def test_token_mode_cannot_write_and_token_is_not_a_write_credential(
agent_server, tmp_path: Path) -> None:
"""A token-mode agent (no bundle) answers the write 403 even when the bearer token is
right: `Authorization` is a read credential, and the write's gate is the certificate.
The 405 arm is the same handler: a POST to any other path is refused before anything
else is checked."""
base, _port = agent_server("k8s-ops-token")
bearer = {"Authorization": "Bearer k8s-ops-token"}
status, body = _post(base + "/route", _route_body(), bearer)
assert status == 403
assert body["detail"] == "writes require a certificate"
for path in ("/health", "/snapshot", "/info", "/schema", "/"):
status, body = _post(base + path, b"{}", bearer)
assert status == 405, path
assert body["detail"] == "no such write path", path
@pytest.mark.parametrize("body, detail", [
('{"id": "x", "action": "disable", "dry_run": true, "extra": 1}',
"body carries a key the write path does not take"),
('{"id": "x", "action": "restart", "dry_run": true}',
"action must be disable or enable"),
(json.dumps({"id": "y" * 257, "action": "disable"}),
"id must be a string of at most 256 characters"),
("[1, 2]", "body is not a JSON object"),
("not json at all", "body is not a JSON object"),
])
def test_a_body_outside_the_vocabulary_is_refused_before_the_peer_is_touched(
body: str, detail: str, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The helper decides what a route id means; the agent decides what a body is. A body
outside `{id, action, dry_run}` is refused before any socket opens, so a bad body can
never reach a proxy config through this door."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
body.encode())
assert status == 422
assert answer["detail"] == detail
peer.stop()
assert peer.connections == 0
def test_an_oversized_body_is_refused_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
oversized = json.dumps({"id": "x" * 5000, "action": "disable"})
assert len(oversized) > host_agent._ROUTE_MAX_BODY
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
oversized.encode())
assert status == 422
assert answer["detail"] == "body longer than the write path accepts"
peer.stop()
assert peer.connections == 0
def test_writes_off_refuses_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The certificate verifies and the helper is listening, but the agent was started
without `--allow-writes`: the refusal is the agent's own, and the dial never happens —
a host that is not armed must not be reachable by an armed dashboard."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
# allow_writes stays False (the fixture's default)
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 403
assert answer["detail"] == "writes are not enabled on this agent"
peer.stop()
assert peer.connections == 0
def test_a_host_without_the_helper_answers_503_not_a_write(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""`--routectl-socket` empty (the flag's documented arm): armed host, no helper unit.
The answer is 503, not a write and not a 200 — a dashboard can tell "not installed"
from "refused" without guessing."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
# routectl_socket stays None (the fixture's default)
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 503
assert answer["detail"] == "no route helper on this host"
@pytest.mark.parametrize("action, dry_run", [("disable", True), ("enable", False)])
def test_a_good_request_reaches_the_peer_once_and_the_answer_is_passed_through(
action: str, dry_run: bool, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The agent is a forward: the body that reaches the helper is exactly `{id, action,
dry_run}` — no header, no token, no invented fields — and the helper's answer (a
refusal here, `applied` stays false) is the response, verbatim. One dial, one request:
a retry would be a second reload."""
peer = _start_peer(tmp_path, reply='{"ok": false, "detail": "ambiguous: whatever"}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
payload = {"id": "nginx:443->brain.test:443", "action": action, "dry_run": dry_run}
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
json.dumps(payload).encode())
assert status == 200
assert answer == {"ok": False, "detail": "ambiguous: whatever"}
assert peer.connections == 1
assert peer.requests == [payload]
peer.stop()
def test_the_peer_is_called_once_even_when_it_times_out(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The helper hangs: the agent's own 30 s budget (patched to 0.3 s here) fires and the
answer is 502 with the reason, never a hang and never a second dial — a retry would be
a second reload of the fleet's only ingress."""
peer = _start_peer(tmp_path, hang=True)
monkeypatch.setattr(host_agent, "ROUTE_TIMEOUT_SECONDS", 0.3)
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper timed out"
assert peer.connections == 1
assert len(peer.requests) == 1
peer.stop()
def test_an_unreadable_peer_answer_is_502_and_the_peer_is_not_retried(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A helper that answers bytes that are not a JSON object is a broken helper: 502 with
the reason, one dial. The agent invents no success and no detail of its own."""
peer = _start_peer(tmp_path, reply="nonsense is not json")
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper answered something unreadable"
assert peer.connections == 1
peer.stop()
def test_every_other_path_is_405_on_the_write_path_and_a_query_string_is_not_a_second_path(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The write path is `/route` and nothing else, on a TLS host with writes on: every
other path is 405 before the certificate, the flag or the body are even looked at. A
query string on `/route` is the same path — the helper decides what the id means."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
context = client_context(tls_fleet.root_cert, tls_fleet.client_cert, tls_fleet.client_key)
for path in ("/health", "/snapshot", "/info", "/schema", "/", "/nope"):
status, answer = _https_post(base + path, context, b"{}")
assert status == 405, path
assert answer["detail"] == "no such write path", path
status, answer = _https_post(base + "/route?from=drill", context, _route_body())
assert status == 200
assert peer.connections == 1
peer.stop()
# ---- the `disabled` field (the read side of the switch) ---------------------- #
def _haproxy_server(snapshot: dict[str, Any], name: str) -> dict[str, Any] | None:
for backend in snapshot["haproxy"]["backends"]:
for server in backend["servers"]:
if server["name"] == name:
return server
return None
def _nginx_proxy_pass(snapshot: dict[str, Any], host: str, port: int) -> dict[str, Any] | None:
for server in snapshot["nginx"]["servers"]:
for entry in server["proxy_passes"]:
target = entry.get("target")
if target is not None and target["host"] == host and target["port"] == port:
return entry
return None
def _nginx_map_entry(snapshot: dict[str, Any], key: str) -> dict[str, Any] | None:
for mapped in snapshot["nginx"]["maps"]:
for entry in mapped["entries"]:
if entry["key"] == key:
return entry
return None
def test_a_marked_haproxy_server_is_disabled_and_stays_in_the_document(gateway_snapshot) -> None:
"""`#off <name>` is a trailing comment, not a deletion: the marked line stays in the
config (the enable half needs it) and the agent says `disabled: true` on the node.
The unmarked neighbours keep `disabled: false` — the field is the marker, nothing else."""
snap = gateway_snapshot("marked")
gitea = _haproxy_server(snap, "gitea")
assert gitea is not None, "the marked line must stay in the document"
assert gitea["disabled"] is True
assert gitea["target"] == "reeseapps-gitea.reeselink.com" and gitea["port"] == 2222
others = [server for backend in snap["haproxy"]["backends"]
for server in backend["servers"] if server["name"] != "gitea"]
assert others, "the fixture's other servers must still be parsed"
assert all(server["disabled"] is False for server in others)
def test_an_unmarked_config_has_disabled_false_everywhere(gateway_snapshot) -> None:
snap = gateway_snapshot("full")
for backend in snap["haproxy"]["backends"]:
for server in backend["servers"]:
assert server["disabled"] is False, server["name"]
for server in snap["nginx"]["servers"]:
for entry in server["proxy_passes"]:
assert entry["disabled"] is False
for pool in snap["nginx"]["upstreams"]:
for member in pool["servers"]:
assert member["disabled"] is False
for mapped in snap["nginx"]["maps"]:
for entry in mapped["entries"]:
assert entry["disabled"] is False
def test_a_marked_vhost_proxy_pass_is_disabled(gateway_snapshot) -> None:
snap = gateway_snapshot("marked")
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None, "the marked proxy_pass must stay in the document"
assert entry["disabled"] is True
plain = gateway_snapshot("full")
plain_entry = _nginx_proxy_pass(plain, "nextcloud.reeselink.com", 11000)
assert plain_entry is not None
assert plain_entry["disabled"] is False
def test_a_marked_map_entry_is_disabled_and_its_value_is_the_sink(gateway_snapshot) -> None:
"""The helper's third arm: the map entry's value is rewritten to the sink address and
the line marked. `disabled` is the marker (true even though the target is a loopback),
and the unmarked capture keeps the real target with `disabled: false`."""
snap = gateway_snapshot("marked")
entry = _nginx_map_entry(snap, "brain.reeseapps.com")
assert entry is not None
assert entry["disabled"] is True
assert entry["target"] == {"host": "127.0.0.1", "port": 44450}
plain = gateway_snapshot("full")
plain_entry = _nginx_map_entry(plain, "brain.reeseapps.com")
assert plain_entry is not None
target = plain_entry["target"]
assert target is not None and target["host"] == "reeseapps-brain-of-reese.reeselink.com"
assert target["port"] == 443
def test_a_hand_edit_is_an_absence_not_a_marker(gateway_snapshot) -> None:
"""`hand_edited` is the operator's other half of the switch: the haproxy line is gone
(the route is off, nothing to mark), the vhost line is plain (on, `disabled: false`),
and the map value is deleted (the entry is there, its target is null, not marked)."""
snap = gateway_snapshot("hand_edited")
assert _haproxy_server(snap, "gitea") is None, "the deleted line is gone"
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None
assert entry["disabled"] is False
mapped = _nginx_map_entry(snap, "brain.reeseapps.com")
assert mapped is not None
assert mapped["target"] is None
assert mapped["disabled"] is False
+1012 -106
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -59,8 +59,10 @@ PROTECTED_API = (
"/api/disks/history",
# Phase 29: the ingress's pipes. `…/snapshot` carries the gateway's whole routing map — every
# domain it serves and every address it dials — so it is listed here for the same reason the
# bundle download is, whatever the payload happens to hold today.
"/api/gateway/snapshot", "/api/gateway/history",
# bundle download is, whatever the payload happens to hold today. Phase 33 task 05 adds the
# surface's one write, `…/route`, beside them: it is the app's second upstream exception
# (invariant 1) and the gate is what keeps it from answering anonymously.
"/api/gateway/snapshot", "/api/gateway/history", "/api/gateway/route",
"/api/llm/snapshot", "/api/llm/history", "/api/ups/snapshot",
"/api/ups/history", "/api/ups/commands", "/api/auth/status",
# Phase 15: the certificate authority's own surface. `/api/certs/<name>/bundle` is every
@@ -80,7 +82,7 @@ PROTECTED_PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/gateway", "/setti
"/certs")
#: The one write this app has, with the fields the route needs (the UPS is unconfigured, so a
#: The write this file exercises, with the fields the route needs (the UPS is unconfigured, so a
#: request that reaches the route answers 404 — that is the point).
WRITE = {"ups": "apc", "command": "beeper.disable", "confirm": "yes"}
@@ -162,8 +164,9 @@ def app(store: SampleStore) -> Any:
"""An app with **no** sources configured, so a route that runs is distinguishable.
No targets, no engines, no NUT: every protected answer that got as far as its handler says
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST
and the UPS POST are the two writes on the app, and only one of them dials anything.
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST,
the UPS POST, and the gateway route POST are the writes on the app, and only two of them
dial anything.
"""
return create_app(settings_for(store.path), store)
@@ -1643,7 +1646,7 @@ def test_every_route_that_is_not_public_requires_a_session(anon: TestClient) ->
gated = [path for path in paths if not is_public(path)]
assert len(gated) == len(paths) - 4, "the public surface grew without this test being read"
assert {"/api/ups/command", "/api/auth/logout"} <= { # the two writes, both gated
assert {"/api/ups/command", "/api/gateway/route", "/api/auth/logout"} <= { # the three writes, all gated
path for path, methods in served if "POST" in methods}
for path in gated:
page = anon.get(path, headers=BROWSER_HEADERS)
+33
View File
@@ -124,6 +124,39 @@ def test_the_local_testing_password_is_prose_and_never_an_assignment(template: P
)
#: The write gate phase 33 added. The pin is the **declaration** — a comment that names
#: the variable (with its default and what enabling it authorises) — never an assignment:
#: a value in a tracked template is a decision made for everyone who copies the file, and
#: the configuration table refuses that trap (the two writes are two decisions, and one
#: flag does not carry the other).
WRITE_GATES = ("GATEWAY_ALLOW_WRITES",)
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_every_write_gate_is_declared_and_never_assigned_in_the_template(template: Path) -> None:
"""A write gate no template names is a gate nobody finds.
The declaration is a comment (a commented-out line, or prose naming the variable with
its default and what enabling it authorises). An *active* assignment in a tracked
template is the gate armed by default for everyone who copies the file —
`GATEWAY_ALLOW_WRITES=1` in a template is a route switch waiting in a fresh
deployment.
"""
text = template.read_text()
for name in WRITE_GATES:
commented = [ln for ln in text.splitlines()
if name in ln and ln.lstrip().startswith("#")]
assert commented, (
f"{template.name} never names {name} in a comment — a write gate nobody finds "
"is a gate whose default nobody can see"
)
active = re.search(rf"^[ \t]*(?:export[ \t]+)?{name}[ \t]*=(?!=)", text, re.MULTILINE)
assert active is None, (
f"{template.name} actively assigns {name} — a value in a tracked template is a "
"decision for everyone who copies it; the pin is the declaration, never a value"
)
def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
"""A dev copying the template needs to know what to type at the first login prompt."""
text = ENV_DEV_EXAMPLE.read_text().lower()
+201
View File
@@ -33,6 +33,7 @@ from dashboards.collectors import (
fetch_snapshot,
gateway_job,
host_jobs,
post_route,
)
from dashboards.config import Settings, Target
from dashboards.poller import Result
@@ -891,6 +892,206 @@ async def test_a_token_mode_snapshot_gains_no_tls_fields() -> None:
# --------------------------------------------------------------------------- #
# post_route (the app's second upstream write, phase 33 task 05)
# --------------------------------------------------------------------------- #
def recorded_post(handler: Any) -> tuple[httpx.AsyncClient, list[httpx.Request]]:
"""The `post_route` shape of `client_for`: a scripted answer, every request kept."""
seen: list[httpx.Request] = []
def recording(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
return httpx.AsyncClient(transport=httpx.MockTransport(recording)), seen
async def test_post_route_posts_the_wire_body_and_returns_the_helpers_object_verbatim() -> None:
"""The wire is task 01's `--serve` contract: `id`, `action`, `dry_run`, and nothing else.
`danger` is the dashboard's typed confirmation, not a wire key — the agent's writer takes a
body with no `danger`, and a header or field smuggled in is a contract break the agent would
answer with its own 422. The answer comes back untouched: an `applied: false` must not
become a success on the way over (rule 2), so the object is handed over, not rebuilt.
"""
client, seen = recorded_post(lambda request: httpx.Response(
200, json={"applied": True, "changed": True, "reloaded": True}))
async with client:
answer = await post_route(client, Target("gateway", "http://agent.test"),
{"id": "brain.reeseapps.com", "action": "disable",
"dry_run": False},
timeout=35.0)
assert answer == {"applied": True, "changed": True, "reloaded": True}
assert len(seen) == 1
request = seen[0]
assert request.method == "POST" and request.url.path == "/route"
assert json.loads(request.content) == {"id": "brain.reeseapps.com",
"action": "disable", "dry_run": False}
assert "authorization" not in {key.lower() for key in request.headers}, (
"the plain path carries only the target's own headers")
async def test_post_route_sends_the_token_on_the_token_path() -> None:
"""A host nobody has bundled dials the compatibility way: the header, exactly as the read sends it.
`parse_targets` is what moves the userinfo token into the header; a directly built target
hands `post_route` the same shape, so the pin is the header arriving on the wire.
"""
client, seen = recorded_post(lambda request: httpx.Response(
200, json={"applied": False, "changed": False}))
target = Target("gateway", "http://agent.test",
headers={"Authorization": "Bearer SEKRIT-AGENT-TOKEN"})
async with client:
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": True},
timeout=35.0)
assert seen[0].headers["Authorization"] == "Bearer SEKRIT-AGENT-TOKEN"
async def test_post_route_sends_no_authorization_on_the_certificate_path() -> None:
"""The bundle is the credential on a `https://` target, and the write is stricter than the read.
`parse_targets` has already moved any token into the header, so a bundled host's target has a
clean URL and no header — and the write must add nothing of its own: a header the read would
have sent (a compatibility host's token) is a second opinion the write refuses, so the
certificate path sends the body and the certificate and nothing else.
"""
seen: list[list[tuple[bytes, bytes]]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.raw)
return httpx.Response(200, json={"applied": False, "dry_run": True, "changed": True})
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as shared:
answer = await post_route(shared, Target("gateway", "https://agent.test"),
{"id": "r", "action": "disable", "dry_run": True},
timeout=35.0, tls=tls)
assert answer["dry_run"] is True
assert len(seen) == 1
assert not any(key == b"authorization" for key, _ in seen[0]), (
"a token next to a certificate is the second opinion the write refuses")
class PostTimeoutRecorder(TimeoutRecordingClient):
"""The same seam as `TimeoutRecordingClient`, for the write's own budget argument."""
async def post(self, url: Any, **kwargs: Any) -> httpx.Response: # type: ignore[override]
self.timeouts.append(kwargs.get("timeout", "<unset>"))
return await super().post(url, **kwargs)
async def test_post_route_carries_the_write_budget_and_never_a_bare_none() -> None:
"""`GATEWAY_WRITE_TIMEOUT_SECONDS` is the write's own 35 s, passed per request.
A `None` timeout is *no timeout at all* to httpx: a helper stuck on a wedged `systemctl
reload` would hold a request open for good. The sentinel keeps the client default, a number
keeps the write's own budget.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"applied": False, "changed": False})
client = PostTimeoutRecorder(transport=httpx.MockTransport(handler),
timeout=10.0, base_url="http://agent.test")
async with client:
await post_route(client, Target("gw", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False}, timeout=35.0)
await post_route(client, Target("gw", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert client.timeouts[-2:] == [35.0, httpx.USE_CLIENT_DEFAULT]
async def test_post_route_timeout_is_a_504_that_names_the_redacted_url() -> None:
"""The message lands in the event table and the browser reads it (invariant 6): the URL is
the redacted one and the status is the 504 a timeout is, so a slow helper is not a 502.
"""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("read timed out")
target = Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, target, {"id": "r", "action": "disable", "dry_run": False})
assert caught.value.status == 504
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
assert "agent.test/route" in caught.value.message
assert "timed out" in caught.value.message
async def test_post_route_unreachable_is_a_502_with_the_redacted_url() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("no route to host")
target = Target("gw", "https://SEKRIT-AGENT-TOKEN@agent.test")
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": False},
tls=tls)
assert caught.value.status == 502
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
async def test_post_route_401_on_the_token_path_is_about_the_token() -> None:
"""A 401 the token path gets is the rotated-token case: the sentence says so, and the URL
is still the redacted one."""
async with client_for(lambda request: httpx.Response(401, json={"detail": "bad token"})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert "rejected the token (401)" in caught.value.message
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
async def test_post_route_refusal_carries_the_helpers_own_word() -> None:
"""A `>= 400` answer is the agent's own refusal — a `403` here is the write gate's own word
(`writes are not enabled on this agent`), and paraphrasing it would be the dashboard
claiming to know more than the peer said."""
async with client_for(lambda request: httpx.Response(
409, json={"detail": "no such id: brain.reeseapps.com"})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "brain.reeseapps.com", "action": "disable", "dry_run": False})
assert caught.value.status == 502
assert "HTTP 409" in caught.value.message
assert "no such id: brain.reeseapps.com" in caught.value.message
async def test_post_route_an_unreadable_answer_is_an_error_with_a_bounded_slice() -> None:
async with client_for(lambda request: httpx.Response(
200, text="not json at all " + "x" * 200)) as client:
with pytest.raises(UpstreamError, match="unreadable") as caught:
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert "x" * 200 not in caught.value.message, "the slice is bounded, not the whole body"
async with client_for(lambda request: httpx.Response(200, json=[1, 2])) as client:
with pytest.raises(UpstreamError, match="non-object"):
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
async def test_post_route_refuses_a_revoked_agent_before_any_dial() -> None:
"""The revocation gate is in front of the network, for the write as for the read.
The scripted transport records what it receives, and a `status` callable that answers
`REVOKED` is what `certs.agent_status` would answer after the operator's click: the
refusal must come before the transport sees a request, not after.
"""
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, json={"applied": True})
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.REVOKED, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as client:
with pytest.raises(UpstreamError, match="agent certificate revoked") as caught:
await post_route(client, Target("gateway", "https://agent.test"),
{"id": "r", "action": "disable", "dry_run": False}, tls=tls)
assert seen == [], "a revoked host must not be written"
assert "revoked" in caught.value.message
# LLM engines
# --------------------------------------------------------------------------- #
GUFO = (FIXTURES / "metrics_gufo.txt").read_text()
+39
View File
@@ -135,6 +135,45 @@ def test_the_gateway_settings_come_from_the_environment(monkeypatch: pytest.Monk
importlib.reload(config)
def test_the_gateway_write_is_off_by_default_and_budgets_are_their_own() -> None:
"""Phase 33 task 05: the flag is a *decision*, and its default is the safe one.
`GATEWAY_ALLOW_WRITES` is `NUT_ALLOW_ACTIONS`'s sibling: the same truthy rule, the same
default, and its own budget. The read's 10 s is the read's alone; the write's 35 s default
is the write's own, and the two never share a number.
"""
settings = Settings()
assert settings.gateway_allow_writes is False
assert settings.gateway_write_timeout == 35.0
assert settings.gateway_timeout == 10.0
def test_the_gateway_write_settings_come_from_the_environment(
monkeypatch: pytest.MonkeyPatch) -> None:
"""`GATEWAY_ALLOW_WRITES=1` is the only way the flag turns on, the same rule as the NUT one."""
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", "1")
monkeypatch.setenv("GATEWAY_WRITE_TIMEOUT_SECONDS", "12.5")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.gateway_allow_writes is True
assert reloaded.SETTINGS.gateway_write_timeout == 12.5
finally:
for name in ("GATEWAY_ALLOW_WRITES", "GATEWAY_WRITE_TIMEOUT_SECONDS"):
monkeypatch.delenv(name)
importlib.reload(config)
def test_the_gateway_flag_uses_the_same_truthy_rule_as_every_other_flag(
monkeypatch: pytest.MonkeyPatch) -> None:
"""The rule is `_flag`'s: the two writes are two decisions, the same shape (task 05)."""
for truthy in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", truthy)
assert config._flag("GATEWAY_ALLOW_WRITES") is True, truthy
for falsey in ("0", "false", "", "no"):
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", falsey)
assert config._flag("GATEWAY_ALLOW_WRITES") is False, falsey
def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None:
"""SMART is not a per-second signal (phase 09), and the fetch waits for the agent's
own 20 s smartctl budget. Both are env-only, like every other interval."""
+383 -1
View File
@@ -30,8 +30,10 @@ import importlib.util
import ipaddress
import json
import os
import pwd
import re
import shutil
import socket
import stat
import subprocess
import sys
@@ -367,7 +369,7 @@ def test_no_shell_function_is_named_after_a_coreutil_it_could_be_asked_to_run(sc
#: The commands of `install()` that would touch the machine running pytest. Each one is a
#: PATH stub that records its arguments and succeeds — the same trick `ScriptedNutClient` is
#: for the UPS wire: fake the privileged boundary, run the real logic.
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl")
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl", "sudo")
#: The three absolute destinations the installer writes to, rewritten into a temp directory
#: so the test exercises the body instead of the operator's `/usr` and `/etc`.
@@ -376,6 +378,16 @@ SANDBOX_REWRITES = {
'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"',
'CERT_FILE="/etc/dashboards-agent.certs"': 'CERT_FILE="{root}/certs"',
'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"',
'ROUTECTL_TARGET="/usr/local/sbin/dashboards-routectl.py"':
'ROUTECTL_TARGET="{root}/sbin/dashboards-routectl.py"',
'ROUTECTL_SOCKET_UNIT="/etc/systemd/system/dashboards-routectl.socket"':
'ROUTECTL_SOCKET_UNIT="{root}/dashboards-routectl.socket"',
'ROUTECTL_SERVICE_UNIT="/etc/systemd/system/dashboards-routectl@.service"':
'ROUTECTL_SERVICE_UNIT="{root}/dashboards-routectl@.service"',
'ROUTECTL_SOCKET_PATH="/run/dashboards-routectl.sock"':
'ROUTECTL_SOCKET_PATH="{root}/run/dashboards-routectl.sock"',
'ROUTECTL_STATE_DIR="/var/lib/dashboards-routectl"':
'ROUTECTL_STATE_DIR="{root}/var-lib/dashboards-routectl"',
}
@@ -396,6 +408,11 @@ def _sandbox_installer(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
script.write_text(text)
(tmp_path / "agent").mkdir()
(tmp_path / "agent" / "host_agent.py").write_text("# stub agent source\n", encoding="utf-8")
# The routectl sources resolve as `$SCRIPT_DIR/…`, so the real files land next to the
# copy: the guard's `[ -f "$_src" ]` check and the re-run `cmp` both need them.
for _name in ("dashboards-routectl.py", "dashboards-routectl.socket",
"dashboards-routectl@.service"):
shutil.copyfile(DEPLOY / _name, script.parent / _name)
stubs = tmp_path / "stubs"
stubs.mkdir()
@@ -2980,3 +2997,368 @@ def test_the_socket_capture_is_idempotent_so_a_re_run_does_not_churn(tmp_path: P
assert result.returncode == 0, result.stdout + result.stderr
assert _fixture_body(again[1] / "gateway_proc_net_tcp.txt") == _fixture_body(
once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt")
# --- the route-switch helper: --with-routectl / --without-routectl (phase 33 task 02) --------
#: The install that adds the helper and its two units, at the sandbox's default bind/port.
ROUTECTL_FLAGS = ("--bind", "10.0.0.5", "--port", "9120", "--with-routectl")
def _target(tmp_path: Path, where: str = "") -> Path:
return tmp_path / where / "target"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_installs_helper_and_units_and_leaves_the_agent_unit_byte_identical(
tmp_path: Path
) -> None:
"""The phase's central safety claim, as a bytes comparison.
Two sandboxes, the same flags, one with the helper: the rendered `dashboards-agent.service`
must be byte-identical — the temptation to "help" the agent reach the socket by relaxing
its sandbox is exactly what this task exists to close, and the comparison is the pin. The
helper and both units land in the other sandbox, byte-identical to the deployed copies, and
the install ends with the socket's activation and the agent-uid reachability check.
"""
plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain")
plain = _install(plain_script, plain_env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0, f"plain install failed:\n{plain.stdout}\n{plain.stderr}"
rc_script, rc_log, rc_env = _sandbox_installer(tmp_path / "rc")
rc = _install(rc_script, rc_env, *ROUTECTL_FLAGS)
assert rc.returncode == 0, f"the routectl install failed:\n{rc.stdout}\n{rc.stderr}"
plain_unit = (
_target(tmp_path, "plain") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "plain")), "<ROOT>")
rc_unit = (
_target(tmp_path, "rc") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "rc")), "<ROOT>")
assert plain_unit == rc_unit, "the agent unit must be byte-identical with and without --with-routectl"
target = _target(tmp_path, "rc")
helper = target / "sbin" / "dashboards-routectl.py"
socket_unit = target / "dashboards-routectl.socket"
service_unit = target / "dashboards-routectl@.service"
assert helper.is_file() and socket_unit.is_file() and service_unit.is_file()
assert helper.read_text() == (DEPLOY / "dashboards-routectl.py").read_text(), (
"the installed helper is a copy, not a re-render"
)
assert socket_unit.read_text() == (DEPLOY / "dashboards-routectl.socket").read_text()
assert service_unit.read_text() == (DEPLOY / "dashboards-routectl@.service").read_text()
calls = rc_log.read_text()
assert "systemctl enable --now dashboards-routectl.socket" in calls
assert "sudo -u dashboards-agent python3" in calls, "the reachability check runs as the agent uid"
assert str(target / "run" / "dashboards-routectl.sock") in calls, (
"the check connects the sandbox's socket path, as the agent uid"
)
def _unit_lines(path: Path) -> list[str]:
return [line.rstrip() for line in path.read_text().splitlines() if line.strip()]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_allow_writes_adds_only_the_flag_to_the_agent_unit(tmp_path: Path) -> None:
"""The write path's one unit change is one argument, and nothing else moves.
Task 07's guard says a re-run diff that shows `NoNewPrivileges`, `ProtectSystem`,
`PrivateDevices` or `SystemCallFilter` moving is a stop, not a `--force` — so this is
the same diff in the sandbox: with `--allow-writes`, the generated unit differs from
the plain install's exactly in the `ExecStart` line, and every security directive is
byte-identical. The flag lands on the unit's own ExecStart, because the agent (not
the installer) is the one that must refuse a write it was not started for.
"""
script, _log, env = _sandbox_installer(tmp_path)
plain = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0
plain_unit = _target(tmp_path) / "dashboards-agent.service"
plain_root = str(_target(tmp_path))
script2, _log2, env2 = _sandbox_installer(tmp_path / "w")
writes = _install(script2, env2, "--bind", "10.0.0.5", "--port", "9120",
"--allow-writes", "--with-routectl")
assert writes.returncode == 0, f"--allow-writes install failed:\n{writes.stderr}"
writes_unit = _target(tmp_path, "w") / "dashboards-agent.service"
plain_lines = _unit_lines(plain_unit)
writes_lines = _unit_lines(writes_unit)
writes_root = str(_target(tmp_path, "w"))
plain_exec = [line_ for line_ in plain_lines if line_.startswith("ExecStart=")]
writes_exec = [line_ for line_ in writes_lines if line_.startswith("ExecStart=")]
assert len(plain_exec) == 1 and len(writes_exec) == 1
writes_exec[0] = writes_exec[0].replace(writes_root, "<ROOT>")
plain_exec[0] = plain_exec[0].replace(plain_root, "<ROOT>")
assert writes_exec[0] == plain_exec[0] + " --allow-writes", (
"the flag is appended to ExecStart, nothing else changes there"
)
assert len(plain_lines) == len(writes_lines), "no other line is added or removed"
for line_ in plain_lines:
if line_.startswith("ExecStart="):
continue
assert line_.replace(plain_root, "<ROOT>") in [
other.replace(writes_root, "<ROOT>") for other in writes_lines
], f"the write path moved a unit line: {line_}"
# The four directives task 07's guard names: they must be byte-identical, not moved.
# `PrivateDevices` is `false` by documented decision (the unit carries no device filter
# at all — see the template's comment), so the pin is on the value that is there.
for directive in ("NoNewPrivileges=true", "ProtectSystem=strict",
"PrivateDevices=false", "SystemCallFilter=@system-service"):
assert directive in writes_unit.read_text(), f"the write path moved {directive}"
assert directive in plain_unit.read_text(), f"the baseline unit lacks {directive}"
# And a flag without a helper is a legitimate host state: it installs, and the agent
# answers a write with 503 rather than a write — the installer has nothing to refuse.
script3, _log3, env3 = _sandbox_installer(tmp_path / "w2")
solo = _install(script3, env3, "--bind", "10.0.0.5", "--port", "9120", "--allow-writes")
assert solo.returncode == 0, f"--allow-writes without --with-routectl must install:\n{solo.stderr}"
assert " --allow-writes" in _target(tmp_path, "w2").joinpath(
"dashboards-agent.service").read_text()
# Non-install modes do not render a unit, so the flag refuses there before any write.
result = _install(script, env, "--status", "--allow-writes")
assert result.returncode == 1
assert "needs an install" in result.stderr
def _serve_on_socket(tmp_path: Path, *, allow_uid: str,
request: bytes) -> tuple[int, str]:
"""Run the real helper as a subprocess the unit does: a connected AF_UNIX socket on
fd 0/fd 1 (`Accept=yes` + `StandardInput=socket`), `--allow-uid`, one request, one
answer line. Returns `(returncode, answer line)` — the process's exit code is the
gate's verdict as much as the line is."""
child_side, parent_side = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", allow_uid,
"--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "serve-state.json")],
stdin=child_side.fileno(), stdout=child_side.fileno(),
stderr=subprocess.PIPE, close_fds=True)
parent_side.sendall(request)
answer = parent_side.recv(65536).decode("utf-8").strip()
parent_side.close()
proc.wait(timeout=60)
child_side.close()
err = proc.stderr.read().decode() if proc.stderr is not None else ""
if proc.returncode not in (0, 1):
raise AssertionError(
f"the helper exited {proc.returncode} instead of answering:\n"
f"{answer}\n{err}")
return proc.returncode, answer
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_answers_one_request_on_the_socket_stdin(tmp_path: Path) -> None:
"""The live box's transport, pinned: with `Accept=yes` + `StandardInput=socket`,
the accepted connection lands on fd 0, and `serve()` must find it there (fd 3 first —
the classic contract — then fd 0), name its peer, and answer one request. The
first live install (phase 33 task 07) answered a real connection with
`peer: no connected socket on this transport` because only fd 3 was looked at;
the same task found the box's kernel delivers `SO_PEERCRED` reversed
(`{pid, uid, gid}`) and `read(2)` on a socket fd that does not wake on
buffered data (`recv(2)` does) — which is why the helper calibrates the uid
slot with a same-process probe and reads the socket path with `recv`."""
me = pwd.getpwuid(os.geteuid()).pw_name
code, answer = _serve_on_socket(
tmp_path, allow_uid=me,
request=b'{"id": "haproxy:80->127.0.0.1:443", "action": "show"}\n')
body = json.loads(answer)
assert isinstance(body, dict), (
f"the answer is one JSON object on the wire (one write, no double): {answer!r}")
assert body.get("ok") is not None, f"the named peer's request was not answered: {answer}"
assert "peer:" not in str(body.get("detail", "")), (
f"the named peer's request was refused by the gate: {answer}")
assert code in (0, 1)
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_refuses_a_wrong_peer_and_a_transport_without_one(
tmp_path: Path) -> None:
"""The second look fails closed in both directions: a named peer that is not
`--allow-uid` is a refusal, and a transport whose caller the kernel will not name
(`/dev/null` on stdin) is a refusal too — the gate never opens on an unnamed caller."""
me = pwd.getpwuid(os.geteuid()).pw_name
_code, answer = _serve_on_socket(tmp_path, allow_uid="root",
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False and "peer:" in body["detail"], (
f"a foreign uid must not pass the gate: {answer}")
# the wire's own gate (a correctly-peered caller): a wider body is a
# refusal with the wire's own sentence — and the answer is a JSON
# *object*, not the (dict, code) tuple a caller-side inversion once
# serialised as a list (phase 33 task 07 caught it on the live chain).
_code, answer = _serve_on_socket(tmp_path, allow_uid=me,
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False, f"a wider body must be refused: {answer}"
assert body["detail"] == "body: exactly {id, action, dry_run}", (
f"the refusal must carry the wire's own sentence: {answer}")
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", me, "--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "pipe-state.json")],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
close_fds=True)
proc.wait(timeout=60)
out = proc.stdout.read().decode("utf-8").strip() if proc.stdout is not None else ""
assert proc.returncode == 1, (
f"an unnamed caller must exit refused (1), not answered: rc={proc.returncode} out={out!r}")
assert out == "", (
f"a pipe transport has no wire; the refusal is the exit code, not a line: {out!r}")
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_units_carry_the_grant_as_directives(tmp_path: Path) -> None:
"""The file-layer grant is a directive systemd enforces; the sandbox pins the lines.
`systemd-analyze verify` is not available in the sandbox, so the assertions are on the
directive lines: the socket is 0600 and agent-owned (the whole access control at the file
layer), the service is root with the socket on stdin and the one-state-directory
`StateDirectory`, and `ReadWritePaths` names every directory whose file the helper
replaces — `/etc/haproxy`, `/etc/nginx/conf.d`, `/etc/nginx/stream.d` — and nothing else
(the main `nginx.conf` is never written, so it must not be listed).
"""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
target = _target(tmp_path)
socket_unit = (target / "dashboards-routectl.socket").read_text()
# Without `Accept=yes` systemd would look for a `dashboards-routectl.service` that does
# not exist (a `Service=` line naming the template does not load on systemd 257) and
# refuse to listen — the live box's first install, phase 33 task 07.
assert _directive(socket_unit, "Accept") == ["Accept=yes"]
assert _directive(socket_unit, "ListenStream") == ["ListenStream=/run/dashboards-routectl.sock"]
assert _directive(socket_unit, "SocketMode") == ["SocketMode=0600"], (
"0600 + SocketUser is the whole access control at the file layer"
)
assert _directive(socket_unit, "SocketUser") == ["SocketUser=dashboards-agent"]
assert _directive(socket_unit, "SocketGroup") == ["SocketGroup=dashboards-agent"]
assert "WantedBy=sockets.target" in socket_unit
service = (target / "dashboards-routectl@.service").read_text()
assert _directive(service, "User") == ["User=root"]
assert "StandardInput=socket" in service and "StandardOutput=socket" in service, (
"one connection, one instance: the connected socket is stdin"
)
assert _directive(service, "StateDirectory") == ["StateDirectory=dashboards-routectl"]
assert _directive(service, "ProtectSystem") == ["ProtectSystem=read-only"]
assert _directive(service, "ReadWritePaths") == [
"ReadWritePaths=/etc/haproxy /etc/nginx/conf.d /etc/nginx/stream.d"
], "os.replace needs write on the directory: every one listed, and nothing the helper never writes"
assert _directive(service, "NoNewPrivileges") == ["NoNewPrivileges=true"], (
"the privilege comes from systemd starting it as root, not from escalation"
)
assert _directive(service, "SystemCallFilter") == ["SystemCallFilter=@system-service"]
assert _directive(service, "ProtectHome") == ["ProtectHome=read-only"]
assert _directive(service, "PrivateTmp") == ["PrivateTmp=yes"]
exec_lines = _directive(service, "ExecStart")
assert len(exec_lines) == 1 and exec_lines[0].startswith(
"ExecStart=/usr/local/sbin/dashboards-routectl.py --serve"
)
assert "--allow-uid dashboards-agent" in service, "the second look: the peer uid the helper accepts"
assert "--state /var/lib/dashboards-routectl/state.json" in service
# The on-disk modes the sandbox's real `install -m` applied.
assert stat.S_IMODE((target / "sbin" / "dashboards-routectl.py").stat().st_mode) == 0o755, (
"the helper is world-executable: the grant is the socket, not the file mode"
)
assert stat.S_IMODE((target / "dashboards-routectl.socket").stat().st_mode) == 0o644
assert stat.S_IMODE((target / "dashboards-routectl@.service").stat().st_mode) == 0o644
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_without_routectl_removes_the_units_and_not_the_config_or_the_state(tmp_path: Path) -> None:
"""The inverse keeps what the removal must keep.
The proxy configs and the helper's state directory are the operator's memory of what was
switched, so `--without-routectl` prints the still-off list first, removes the helper and
its two units, and leaves the state (and the configs) behind, ending with the reminder.
"""
script, call_log, env = _sandbox_installer(tmp_path)
assert _install(script, env, *ROUTECTL_FLAGS).returncode == 0
target = _target(tmp_path)
state_dir = target / "var-lib" / "dashboards-routectl"
state_dir.mkdir(parents=True)
state_dir.joinpath("state.json").write_text('{"nginx:443->127.0.0.1:44450": {}}', encoding="utf-8")
result = _install(script, env, "--without-routectl")
assert result.returncode == 0, f"--without-routectl failed:\n{result.stdout}\n{result.stderr}"
assert not (target / "dashboards-routectl.socket").exists(), "the socket unit must be removed"
assert not (target / "dashboards-routectl@.service").exists(), "the service unit must be removed"
assert not (target / "sbin" / "dashboards-routectl.py").exists(), "the helper must be removed"
assert (state_dir / "state.json").is_file(), "the state is the memory of what was switched; it stays"
calls = call_log.read_text()
assert "systemctl disable --now dashboards-routectl.socket" in calls
assert "daemon-reload" in calls
out = result.stdout
assert "routes the helper has switched off" in out, "the still-off list prints first"
assert "still off" in out, "the reminder ends the removal, not the file list"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_with_and_without_flags_do_not_mix(tmp_path: Path) -> None:
"""One flag each direction, before any write."""
script, call_log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--with-routectl", "--without-routectl")
assert result.returncode == 1
assert result.stdout == ""
assert "one flag each direction" in result.stderr
assert not call_log.exists(), "refused before a single privileged command"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_needs_an_install(tmp_path: Path) -> None:
"""`--status` probes a port; `--uninstall` deletes the agent. Neither installs files."""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--status", "--with-routectl")
assert result.returncode == 1
assert "needs an install" in result.stderr
def test_no_new_sudoers_line_exists() -> None:
"""The grant is a file mode systemd enforces; the pin is the absence of a sudoers rule.
`deploy/agent-sudoers` is still exactly one line (the staged installer), neither of the
two new units names sudo, and the operator page says the sentence out loud.
"""
assert _grant_lines(SUDOERS.read_text()) == [CANONICAL_GRANT], (
"phase 33 adds a write without adding a sudoers line; a new rule is the regression"
)
for name in ("dashboards-routectl.socket", "dashboards-routectl@.service"):
text = (DEPLOY / name).read_text()
# `sudo\b` is the word sudo by itself: the units' own "not a sudoers line" comment
# (the negation that is the point) does not match, a grant would.
assert not re.search(r"sudo\b", text.lower()), f"{name} grants nothing by sudoers"
block = INSTALLER.read_text().split("routectl_off() {", 1)
assert len(block) == 2, "the --without-routectl path is a function"
assert "sudoers" not in block[1].split("\n}\n", 1)[0].lower(), (
"the removal path must not touch sudoers either"
)
readme = (DEPLOY / "gateway-routectl-README.md").read_text()
assert "No sudoers line is involved" in readme
assert "0600" in readme, "the operator page names the mode that is the grant"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_install_checks_the_agent_can_reach_the_socket(tmp_path: Path) -> None:
"""A socket the agent cannot connect to is a feature that fails at 03:00.
So the check is part of the install: with the check's `sudo -u` step failing, the install
must exit non-zero rather than report a success nobody can use, and the check must have
run (the stub log is the proof it connected as the agent uid, not just been skipped).
"""
script, call_log, env = _sandbox_installer(tmp_path)
sudo_stub = tmp_path / "stubs" / "sudo"
sudo_stub.write_text(
'#!/bin/sh\nprintf "%s %s\\n" "sudo" "$*" >> "$STUB_LOG"\nexit 1\n', encoding="utf-8"
)
sudo_stub.chmod(0o755)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 1, "an unreachable socket is a failed install, not a silent success"
assert "cannot connect" in result.stderr
assert "sudo -u dashboards-agent python3" in call_log.read_text(), "the check ran as the agent uid"
+368 -1
View File
@@ -1696,7 +1696,7 @@ def test_no_count_carries_a_verdict_or_a_window(gateway_snapshot: Any) -> None:
`sources/host.py` fails here — the first would be a judgement `severity.py` never made, the
second would claim a measurement this source does not take.
"""
for mode in ("full", "predates", "sockets_silent"):
for mode in ("full", "predates", "sockets_silent", "marked", "hand_edited"):
keys = walk_keys(gateway.parse(gateway_snapshot(mode), "gateway"))
assert "severity" not in keys, mode
assert not {key for key in keys if "window" in key or key.startswith("Δ")}, mode
@@ -2132,3 +2132,370 @@ def test_warnings_travel_in_the_payload_and_the_stub_share_the_key(gateway_snaps
assert gateway.stub("gateway")["warnings"] == []
assert gateway.parse(gateway_snapshot("predates"), "gateway")["warnings"] == []
assert set(gateway.stub("gateway")) - {"unreachable"} <= set(gateway.parse(snap, "gateway"))
# --------------------------------------------------------------------------- #
# phase 33 task 04: `enabled` on every line, and the warnings learn the marker
#
# The helper's `#off <name>` marker leaves a switched-off line **in the config**; the agent
# reads it and publishes `disabled`, and this module draws the line either way — an off route
# is a claim the config made (a different fact from "never declared"), so it is drawn and
# counted, flagged. An arm that never read the config draws no lines at all, which is what
# keeps a `false` out of a payload nobody answered. The warning arms learn the marker the same
# way: a shape the operator's own switch caused is not a fault, and the same shape a hand edit
# left is.
# --------------------------------------------------------------------------- #
def marked(row: dict[str, Any]) -> dict[str, Any]:
"""A line the helper marked off: the agent's own `disabled: true` beside the intact line."""
return {**row, "disabled": True}
def test_a_marked_haproxy_server_line_is_an_off_line_with_a_distinct_id() -> None:
"""On and off are both drawn; the id's `#off` suffix is what keeps them distinct rows."""
section = haproxy_section(
[frontend("haproxy-22", [2222], default="be")],
[backend("be", [server("s1", "10.9.9.9", 80),
marked(server("s2", "10.9.9.9", 80))])])
payload = gateway.parse(snapshot(haproxy=section, agent_version="1.4.0"), "gateway")
by_enabled = {row["enabled"]: row for row in payload["connections"]}
assert set(by_enabled) == {True, False}, "one of these selectors is off is two facts"
on, off = by_enabled[True], by_enabled[False]
assert on["id"] == "haproxy:2222->10.9.9.9:80"
assert off["id"] == "haproxy:2222->10.9.9.9:80#off", (
"the suffix is the write path's key — the two halves of the split must not collide")
assert on["out"]["selectors"] == off["out"]["selectors"] == ["be"], (
"the off line keeps the config's own word for the route — switched off is not deleted")
# the flag is absence-tolerant: a row the agent did not report it on (an older agent's
# document) is on, because absence is "not switched off", never "broken"
older = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [server("s1", "10.9.9.9", 80)])])
payload = gateway.parse(snapshot(haproxy=older), "gateway")
assert [row["enabled"] for row in payload["connections"]] == [True]
def test_a_backend_whose_only_server_is_switched_off_is_not_an_empty_warning() -> None:
"""The marker decision, pinned both ways: the line the helper left behind keeps the backend
non-empty (no warning — its state is the line's `enabled: false`), and the same shape the
operator left by **deleting** the line is the fault."""
switched_off = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
assert gateway.warnings(switched_off, None) == [], (
"the line is still in the document, so the backend declares a server — the off state "
"is the line's flag, not an empty-backend fault")
hand_deleted = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [])])
assert kinds(gateway.warnings(hand_deleted, None)) == [gateway.WARN_BACKEND_EMPTY], (
"the config no longer says anything — that is the fault the same shape warns about")
def test_a_marked_proxy_pass_is_an_off_line_and_the_block_is_still_drawn() -> None:
off = {**proxy_pass(("10.9.9.9", 80), location="/off"), "disabled": True}
section = nginx_section(
[nginx_server([8443], ["x.example"], [proxy_pass(("10.9.9.9", 80)), off])])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_enabled = {row["enabled"]: row for row in payload["connections"]}
assert set(by_enabled) == {True, False}
assert by_enabled[False]["id"] == "nginx:8443->10.9.9.9:80#off"
assert by_enabled[False]["out"]["selectors"] == ["x.example"]
def test_a_marked_upstream_member_is_off_and_its_siblings_stay_on() -> None:
pool = {"name": "pool",
"servers": [{"host": "10.9.9.1", "port": 80, "disabled": False},
{"host": "10.9.9.2", "port": 80, "disabled": True}]}
section = nginx_section([nginx_server([8443], ["x.example"], [pool_pass("pool")])],
upstreams=[pool])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_host = {row["out"]["host"]: row["enabled"] for row in payload["connections"]}
assert by_host == {"10.9.9.1": True, "10.9.9.2": False}, (
"the member line's own flag is its state — the pool's other members are not switched "
"with it")
def test_a_map_entry_is_off_by_marker_or_by_the_sink_block_and_only_those() -> None:
"""Three off-lookalikes, one of which is routing: the marked line, the line whose value is
the helper's sink block, and a plain loopback target with no such block. Only the first two
are the switch — an address alone, without the block, is not off (a vhost that legitimately
lands on loopback is routing, not a switch)."""
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 19999}],
"server_names": [], "proxy_passes": []}
entries = [("web.example", ("10.9.9.9", 80)),
("off.example", ("10.8.8.8", 80)),
("sink.example", ("127.0.0.1", 19999))]
maps = [mapping_block("$var", entries)]
maps[0]["entries"][1] = {**maps[0]["entries"][1], "disabled": True} # `off.example`, marked
section = nginx_section(
[sink_block,
nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=maps)
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_selector = {row["out"]["selectors"][0]: row["enabled"]
for row in payload["connections"] if row["out"]["selectors"]}
assert by_selector == {"web.example": True, "off.example": False,
"sink.example": False}, (
"the marked line and the line whose value is the sink block are the switch; "
"a plain target is not")
# the control target: a loopback address the config routes to, with no such block
maps[0]["entries"].append({"key": "plain.example",
"target": {"host": "127.0.0.2", "port": 29999},
"disabled": False})
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
plain = [row for row in payload["connections"]
if row["out"]["selectors"] == ["plain.example"]]
assert plain and plain[0]["enabled"] is True, (
"an address alone, without the block, is not off")
# and the refusal the other way: the same config with the sink block absent — the value is
# just an address again, and the line is on
no_sink = dict(section, servers=[s for s in section["servers"]
if s is not sink_block])
payload = gateway.parse(snapshot(nginx=no_sink, agent_version="1.4.0"), "gateway")
sink_row = [row for row in payload["connections"]
if row["out"]["selectors"] == ["sink.example"]]
assert sink_row and sink_row[0]["enabled"] is True
def test_a_map_entry_with_no_value_warns_unless_the_line_carries_the_marker() -> None:
"""The same shape, two causes: the value hand-deleted is the fault the warning exists for,
the value the marker's line lost is the operator's own state and stays quiet. A marked entry
with no value has no endpoint to draw either, so it is a warning-less absence — the off
state lives in the line's flag, and the line is the config's."""
entries = [{"key": "gone.example", "target": None, "disabled": False},
{"key": "off.example", "target": None, "disabled": True}]
section = nginx_section(
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[{"variable": "$var", "entries": entries}])
assert kinds(gateway.warnings(None, section)) == [gateway.WARN_MAP_ROUTE_MISSING]
found = gateway.warnings(None, section)[0]
assert found["subject"] == "var:gone.example"
# the marked entry draws no line (it has no endpoint) and warns nothing
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
assert all(row["out"]["selectors"] != ["off.example"] for row in payload["connections"])
def test_two_marked_map_entries_to_one_endpoint_merge_into_one_off_line() -> None:
"""Same state, same endpoint, two selector words: one line, the union of both keys — the
merge rule with `enabled` in the key, in the direction that unions instead of splits."""
entries = [{"key": "a.example", "target": {"host": "10.9.9.9", "port": 80},
"disabled": True},
{"key": "b.example", "target": {"host": "10.9.9.9", "port": 80},
"disabled": True}]
section = nginx_section(
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[{"variable": "$var", "entries": entries}])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
assert len(payload["connections"]) == 1
row = payload["connections"][0]
assert row["enabled"] is False and row["id"] == "nginx:443->10.9.9.9:80#off"
assert row["out"]["selectors"] == ["a.example", "b.example"]
def test_the_stub_and_the_predates_arm_have_no_lines_and_so_no_false(
gateway_snapshot: Any) -> None:
"""`false` is always a claim the config made, so an arm that never read the config carries
no `enabled` at all — not a row of `null`s (that would be a guess) and not `false` (that
would be a claim). The stub and the predates arm are that arm, both pinned."""
assert gateway.stub("gateway")["connections"] == []
assert "enabled" not in walk_keys(gateway.stub("gateway"))
assert "disabled" not in walk_keys(gateway.stub("gateway"))
predates = gateway.parse(gateway_snapshot("predates"), "gateway")
assert predates["supported"] is False and predates["connections"] == []
assert "enabled" not in walk_keys(predates)
def test_the_captured_configs_carry_no_marker_and_their_lines_are_on(gateway_snapshot: Any) -> None:
"""The recorded ingress, through the real agent: the flag is on every markable line and no
line of it is marked, so every line of the captured graph is `enabled: true` — the pin that
a re-capture in which the operator switched a route off is visible here rather than silently
redrawn as on."""
snap = gateway_snapshot("full")
for block in snap["haproxy"]["backends"]:
assert all(row["disabled"] is False for row in block["servers"])
for server in snap["nginx"]["servers"]:
assert all(p["disabled"] is False for p in server["proxy_passes"])
for block in snap["nginx"]["upstreams"]:
assert all(m["disabled"] is False for m in block["servers"])
for block in snap["nginx"]["maps"]:
assert all(e["disabled"] is False for e in block["entries"])
payload = gateway.parse(snap, "gateway")
assert payload["connections"], "the capture has routes"
assert all(row["enabled"] is True for row in payload["connections"])
assert not any(row["id"].endswith("#off") for row in payload["connections"])
def test_a_marker_off_route_is_declared_and_disabled(gateway_snapshot: Any) -> None:
"""Three of the fleet's own routes, switched off in the `marked` fixture (one haproxy
`server`, one vhost `proxy_pass`, one map entry pointing at the sink), come back as three
drawn rows reading `enabled: false` — selector and endpoint intact, every other row on, and
the marker-caused shapes warn nothing. The row is *declared and off*, which is a different
fact from never declared (absent) and from on, and the `#off` id suffix is the state's
spelling the write path keys on.
"""
payload = gateway.parse(gateway_snapshot("marked"), "gateway")
off = [row for row in payload["connections"] if row["enabled"] is False]
on = [row for row in payload["connections"] if row["enabled"] is True]
assert len(off) == 3, "three markers, three off rows — no merge stole one, none is missing"
assert on, "the other routes of the capture are still declared and on"
assert all(row["enabled"] is True for row in on), ("every non-marker row is on")
by_selector = {tuple(row["out"]["selectors"]): row for row in off}
# the selectors are the config's own words, verbatim — switching a route off is not deleting
# its name, and the endpoint is what the config (or the helper's rewrite) wrote, intact
assert by_selector[("gitea-2222",)]["out"]["host"] == "reeseapps-gitea.reeselink.com"
assert by_selector[("gitea-2222",)]["out"]["port"] == 2222
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["host"] == "nextcloud.reeselink.com"
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["port"] == 11000
assert by_selector[("brain.reeseapps.com",)]["out"]["host"] == "127.0.0.1"
assert by_selector[("brain.reeseapps.com",)]["out"]["port"] == 44450
for row in off:
assert row["id"].endswith("#off"), "the off state is the id's spelling, on every off row"
# the two marker-caused shapes are present in the raw sections, so `warnings == []` below is a
# suppression, not a vacuous absence: the gitea backend's only server is marked, and the
# brain entry's value is the sink
snap = gateway_snapshot("marked")
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert gitea["servers"] and gitea["servers"][0]["disabled"] is True
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["disabled"] is True
assert payload["warnings"] == [], ("a marker-caused empty backend and a sink-pointed map "
"entry are states, not config-integrity faults")
def test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it(
gateway_snapshot: Any) -> None:
"""The positive direction of both marker-aware kinds, over the captured config with the
markers injected (never a toy file): a backend whose only `server` the operator turned off
is not `haproxy_backend_empty` — the line is still in the document, and its state is the
row's `enabled: false` — and a map entry the helper pointed at its own sink is not a
missing route. Neither shape may earn a warning while the switch is what made it.
"""
snap = gateway_snapshot("marked")
# the shapes are present in the raw document, so the silence below is a decision
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert len(gitea["servers"]) == 1 and gitea["servers"][0]["disabled"] is True
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["disabled"] is True and brain["target"] == {"host": "127.0.0.1", "port": 44450}
payload = gateway.parse(snap, "gateway")
assert kinds(payload["warnings"]) == [], (
"the operator's own switch is a state the page draws, not a fault the page alarms about")
def test_the_same_shape_without_a_marker_is_still_a_warning(gateway_snapshot: Any) -> None:
"""The negative direction, the same two shapes in the `hand_edited` fixture: the `server`
line deleted by hand is the fault `haproxy_backend_empty` exists for, and the map entry
whose value was hand-deleted (no marker, no sink file) is the fault
`nginx_map_route_missing` exists for. Suppression is marker-caused, not shape-caused —
together with `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it`
this is the pair that says so.
"""
snap = gateway_snapshot("hand_edited")
# the shapes are present and unmarked, so the warnings below are the checks working
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert gitea["servers"] == [], "the line is gone — the backend declares no server in the document"
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["target"] is None and brain["disabled"] is False
payload = gateway.parse(snap, "gateway")
assert kinds(payload["warnings"]) == [gateway.WARN_BACKEND_EMPTY,
gateway.WARN_MAP_ROUTE_MISSING]
found = {row["kind"]: row for row in payload["warnings"]}
assert found[gateway.WARN_BACKEND_EMPTY]["subject"] == "gitea-2222"
assert found[gateway.WARN_MAP_ROUTE_MISSING]["subject"] == "targetBackend:brain.reeseapps.com"
# and the off state itself is gone with the marker: no `#off` row, nothing to draw off
assert not any(row["id"].endswith("#off") for row in payload["connections"])
assert all(row["enabled"] is True for row in payload["connections"])
def test_the_sink_address_is_recognised_not_hard_coded() -> None:
"""The sink is the *block* — loopback listen, no `server_name`, no `proxy_pass` — and the
port is the helper's to choose. A config that writes a different sink port still reads as
switched-off from the payload's own text, and a loopback target that is not the block's
port is routing, not a switch (a vhost that legitimately lands on loopback is on)."""
other_port = 45999
sink_block = {"listen": [{"addr": "127.0.0.1", "port": other_port}],
"server_names": [], "proxy_passes": []}
section = nginx_section(
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[mapping_block("$var", [("sink.example", ("127.0.0.1", other_port)),
("plain.example", ("127.0.0.2", 29999))])])
assert gateway.sink_endpoint(section) == ("127.0.0.1", other_port), (
"the block, not the port constant, is what the source looks for")
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_selector = {row["out"]["selectors"][0]: row["enabled"]
for row in payload["connections"] if row["out"]["selectors"]}
assert by_selector == {"sink.example": False, "plain.example": True}, (
"the entry whose value is the block's listen is off; the other loopback target is not")
def test_a_disabled_line_still_publishes_only_a_measured_count() -> None:
"""Nothing stamps `0`: an off row's `connections` is the join's answer, and the join says
`0` only when the table answered (and genuinely had none) and `null` when it did not —
with no sample in the latter case. `0` measured is a legitimate measurement; the
distinction is *measured*, never *assumed*.
"""
haproxy = haproxy_section([frontend("f", [2222], default="be")],
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 44450}],
"server_names": [], "proxy_passes": []}
section = nginx_section(
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[mapping_block("$var", [("brain.example", ("127.0.0.1", 44450)),
("web.example", ("10.9.9.1", 80))])])
section["maps"][0]["entries"][0] = {**section["maps"][0]["entries"][0], "disabled": True}
# the table answered (and had nothing to book): the off rows are `0`, and `0` measured
# publishes, because the endpoint is one the config wrote as an address
answered = gateway.parse(
snapshot(haproxy=haproxy, nginx=section, sockets=sockets_section(),
agent_version="1.4.0"), "gateway")
off = [row for row in answered["connections"] if row["enabled"] is False]
assert len(off) == 2 and all(row["connections"] == 0 for row in off)
by_selector = {row["out"]["selectors"][0]: row for row in answered["connections"]}
assert by_selector["web.example"]["connections"] == 0 # the on row, the same measured zero
off_metrics = {row["metric"] for row in off}
assert off_metrics, "a joinable off endpoint carries a gauge, like its on counterpart"
samples = {s.metric: s.value for s in gateway.samples(answered, 1.0)}
for metric in off_metrics:
assert samples.get(metric) == 0.0, "the off row's sample is the measured zero, not a stamp"
# the table did not answer: every row stays `null` and the off rows publish nothing at all
silent = gateway.parse(
snapshot(haproxy=haproxy, nginx=section,
sockets=sockets_section(available=None), agent_version="1.4.0"), "gateway")
assert all(row["connections"] is None for row in silent["connections"]), (
"no table, no count — the off state is not a zero")
assert gateway.samples(silent, 1.0) == []
def test_the_stub_and_the_answer_share_one_key_set(gateway_snapshot: Any) -> None:
"""The stub's `enabled` is `None` in the sense nobody read the config — and a stub has no
rows, so the honest unknown is the *absence of the claim*: no rows, hence no `enabled` key
anywhere in the document (a row of `null`s would be a guess, and a `false` would be a claim
the config never made). The answer is the other half of the contract: every row it draws
carries the state as a real boolean, never a `None` and never a missing key, so a page
indexing `connections[].enabled` tests one shape in both arms.
"""
answer = gateway.parse(gateway_snapshot("full"), "gateway")
assert answer["connections"], "the capture has routes to pin the answer half on"
for row in answer["connections"]:
assert row["enabled"] is True or row["enabled"] is False, (
"an answered row's state is a claim the config made — never null, never absent")
stub = gateway.stub("gateway")
assert stub["connections"] == []
assert "enabled" not in walk_keys(stub), (
"an arm that never read the config carries no `enabled` — its `None` is the absence")
assert "disabled" not in walk_keys(stub)
# top-level parity: the page indexes the same keys on both arms; the only keys the stub
# alone or the answer alone carries are the ones that *are* the difference
shared = set(stub) | set(answer)
assert shared - set(stub) == {"agent_version"}, "the answer's extra keys are the snapshot's"
assert shared - set(answer) == {"unreachable"}, "the stub's extra key is the reachability one"
+125 -3
View File
@@ -14,9 +14,11 @@ addresses the deployed dashboard dials, nothing forwarded:
Each test skips when its source is not configured, so running the whole set with half a
fleet reachable is a normal outcome, not a failure.
**Nothing in here may write.** The UPS test asserts the command gating is honest; it never
sends an instant command — a battery test on hardware someone else is plugged into is not
a unit test.
**Nothing in here may write — with exactly one documented exception.** The UPS test asserts
the command gating is honest; it never sends an instant command — a battery test on
hardware someone else is plugged into is not a unit test. The one write is the phase-33
round-trip test, gated by its own flag (`--allow-live-writes`) on top of `--live`: a
default live run never switches a route, and the skip reason names the route it would.
"""
from __future__ import annotations
@@ -562,3 +564,123 @@ def test_target_config_is_clean_and_leak_free(settings: Settings) -> None:
assert "@" not in target.display_url, f"{target.name}: display_url leaks credentials"
for problem in settings.host_target_problems:
assert isinstance(problem, str) and problem, f"empty problem: {problem!r}"
#: The one write target of the opt-in round trip (phase 33 task 07): a benchmark service
#: with no human dependency. Never the auth, git, alerts or file routes — task 07's
#: precondition names them, and a test that would touch one of them is a test that does not
#: run.
SWITCH_DRILL_ROUTE = "haproxy:5201->web.reeselink.com:5201"
async def test_the_switch_round_trip_on_one_named_route(
request: pytest.FixtureRequest, settings: Settings,
agent_tls: dict[str, collectors.AgentTls]) -> None:
"""Phase 33's one live write: the named route off and back on, through the app's own
`POST /route` dial.
Gated twice so a default live run stays read-only: the file's `live` marker (skip
without `--live`) **and** the separate `--allow-live-writes` flag, whose skip reason
names the route it would touch. When it does run — the operator's drill, task 07 step
5 — it dials `collectors.post_route` (the app's second and last upstream write, through
the stored mTLS authority, no hand-rolled client) and proves the round trip on the
production read (`fetch_snapshot` → `gateway.parse`): the helper's own words for the
write, the row's `enabled` flipping both ways, the agent's own section carrying the
marked line in between, and the haproxy section the agent serves byte-identical between
the pre-drill and post-drill reads. The restore lives in a `finally`: even a failed
proof turns the route back on before the test ends, and a re-enable that itself fails
propagates the helper's own word — a marked line left behind must be the operator's
first line, never a silent one.
"""
if not bool(request.config.getoption("--allow-live-writes")):
pytest.skip(f"opt-in live write: skip without --allow-live-writes (this test would "
f"switch {SWITCH_DRILL_ROUTE} off and back on — the default live suite "
"stays read-only")
target = next((item for item in settings.host_targets if item.name == settings.gateway_host),
None)
if target is None:
pytest.skip(f"GATEWAY_HOST={settings.gateway_host!r} is not in HOST_TARGETS")
async def read_ingress(client: httpx.AsyncClient) -> tuple[dict[str, Any], dict[str, Any]]:
snapshot = await collectors.fetch_snapshot(client, target,
timeout=settings.gateway_timeout,
tls=agent_tls.get(target.name))
return snapshot, gateway.parse(snapshot, target.name)
async def post(body: dict[str, Any]) -> dict[str, Any]:
# One client per call: one write, one connection — the same discipline the agent
# keeps for its one helper dial (a retry of a write is a second reload).
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
return await collectors.post_route(client, target, body,
timeout=settings.gateway_timeout,
tls=agent_tls.get(target.name))
disabled = False
try:
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
try:
before, payload = await read_ingress(client)
except collectors.UpstreamError as exc:
pytest.skip(f"{target.name} did not answer: {exc}")
# Precondition 1: the agent on this host is the one with the write path.
version = str(before.get("agent_version") or "")
if not version.startswith("1.4."):
pytest.skip(f"{target.name} runs agent {version or 'unknown'} — POST /route is "
"1.4.0's; re-stage with deploy/install-agent.sh --allow-writes "
"--with-routectl")
# Precondition 2: a clean pre-state — the route is on and nothing else is off;
# a half-finished earlier attempt is the state task 07 step 4 refuses to drill.
route = next((row for row in payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE), None)
if route is None:
pytest.skip(f"{SWITCH_DRILL_ROUTE} is not on the ingress the agent published — "
"the drill target moved; update this test's target and re-verify")
already_off = [row["id"] for row in payload["connections"]
if row["enabled"] is False]
assert not already_off, (
f"a route is already disabled before the drill {already_off} — a previous "
"attempt left a marked line; restore it before re-running (task 07 step 6)")
# 1. dry_run: the diff, and no write (task 07 step 5.1).
preview = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable",
"dry_run": True})
assert preview.get("ok") is True and preview.get("dry_run") is True, preview
assert preview.get("applied") is False and preview.get("changed") is True, preview
assert any("#off" in str(line) for line in preview.get("diff") or []), (
f"the dry-run diff must show the marker landing: {preview.get('diff')!r}")
# 2. apply: the helper's own word for the write (step 5.2). A checker that refuses
# (e.g. no `nginx -t` on the host's PATH) answers `applied: false, restored: true`
# and this test fails on the helper's word instead of printing a false green.
applied = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable"})
assert applied.get("ok") is True and applied.get("applied") is True, (
f"the helper refused the write instead of applying it: {applied!r}")
assert applied.get("validated") is True and applied.get("reloaded") is True, applied
disabled = True
# 3. the off state, proven on the production read (step 5.3/5.4): the row and the
# agent's own section both report the marker.
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
off_snapshot, off_payload = await read_ingress(client)
off_route = next(row for row in off_payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE)
assert off_route["enabled"] is False, "the row must report disabled within one read"
marked = [server for backend in (off_snapshot.get("haproxy") or {}).get("backends", ())
for server in backend.get("servers", ()) if server.get("disabled") is True]
assert marked, "the agent's haproxy section shows no marked server line"
finally:
# The restore lives here, not after the assertions: a failed proof still leaves the
# route on, and a failed re-enable propagates the helper's own word.
if disabled:
await post({"id": SWITCH_DRILL_ROUTE, "action": "enable"})
# 4. the round trip closed: the row is on, nothing is off, and the haproxy section the
# agent serves is byte-identical to the pre-drill read (step 5.5/5.6).
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
after, after_payload = await read_ingress(client)
after_route = next(row for row in after_payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE)
assert after_route["enabled"] is True, (
f"the route is still off after the re-enable — the drill left a marked line: "
f"{after_route!r}")
left_off = [row["id"] for row in after_payload["connections"]
if row["enabled"] is False]
assert not left_off, f"marked lines left behind after the round trip: {left_off}"
assert json.dumps(after.get("haproxy")) == json.dumps(before.get("haproxy")), (
"the haproxy section is not byte-identical after the round trip")
+625
View File
@@ -0,0 +1,625 @@
"""The route-switch root helper, tested unprivileged against a fake tree.
`deploy/dashboards-routectl.py` is the phase 33 writer: the dashboard reaches a
gateway config file only through it (through the agent's `POST /route`, through the
systemd-activated socket). Its safety property is the write protocol's order — locate,
state, edit, validate, reload, prove, record — and the refusals that end it before a
byte moves. So the tests run the **real helper** (`--root` pointed at a fake tree),
against the **captured** gateway configs (`tests/fixtures/gateway_*.cfg|.conf`, the
fleet's real ingress bytes, never a toy file), with `nginx` / `haproxy` / `systemctl`
faked on `PATH` as scripts that record their argv and fail on order. The assertions
are on the bytes: one line changed and nothing else, a byte-identical restore, a
refusal that wrote nothing, the state sidecar's mode at birth, the sink's header and
its removal rule, and the socket's peer gate. The helper's own `--serve` is driven the
way the unit does (the connected socket on stdin), and the structural pins (no
`shell=True`, no path from the wire, the one `systemctl` verb, the Python 3.10 floor)
are greps over the file, the `auth.py`/`certs.py` precedent.
"""
from __future__ import annotations
import argparse
import ast
import hashlib
import importlib.util
import json
import os
import pwd
import re
import socket
import stat
import subprocess
import sys
import types
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy"
FIXTURES = ROOT / "tests" / "fixtures"
HELPER = DEPLOY / "dashboards-routectl.py"
AGENT = ROOT / "agent" / "host_agent.py"
#: The captured gateway tree, the same mapping `tests/conftest.py` lays out — the
#: helper's `--root` reads the file the real ingress runs.
GATEWAY_TREE = {
"etc/haproxy/haproxy.cfg": "gateway_haproxy.cfg",
"etc/nginx/nginx.conf": "gateway_nginx_main.conf",
"etc/nginx/conf.d/http.conf": "gateway_nginx_conf_d_http.conf",
"etc/nginx/stream.d/stream.conf": "gateway_nginx_stream_d_stream.conf",
}
#: The ids the dashboard publishes for these lines (phase 30's vocabulary), and the
#: two refusals that are not ids at all.
HAPROXY_ROUTE = "haproxy:5201->web.reeselink.com:5201" # the iperf3 server line
VHOST_ROUTE = "nginx:4443->nextcloud.reeselink.com:11000" # the 4443 vhost's proxy_pass
MAP_ROUTE = "nginx:443->reeseapps-brain-of-reese.reeselink.com:443" # brain.reeseapps.com
MAP_SINK_ID = "nginx:443->127.0.0.1:44450" # while off, the map entry is addressed by its sink value
IMMICH_ROUTE = "nginx:443->reeseapps-immich.reeselink.com:443"
MAP_DEFAULT = "nginx:443->127.0.0.1:4443" # the map's default: a fallback, not a service
SINK_HEADER = "# managed by dashboards-routectl — do not edit"
IMMICH_ORIGINAL = " immich.reeseapps.com reeseapps-immich.reeselink.com:443;"
def _tree(tmp_path: Path) -> Path:
"""The captured configs, laid into a fake `--root` tree byte for byte."""
root = tmp_path / "root"
for relative, fixture in GATEWAY_TREE.items():
target = root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes((FIXTURES / fixture).read_bytes())
return root
def _stubs(tmp_path: Path) -> tuple[Path, Path]:
"""Fake `nginx` / `haproxy` / `systemctl` on `PATH`: they record their argv in
`$FAKE_CALLS_LOG` and fail on order — `FAKE_CHECK_FAIL` makes the checker exit 1
with its own first line, `FAKE_RELOAD_FAIL` makes `reload` fail and `status`
print the unit's line."""
stubs = tmp_path / "stubs"
stubs.mkdir()
log = tmp_path / "calls.log"
for name in ("nginx", "haproxy", "systemctl"):
script = stubs / name
script.write_text(
"#!/bin/sh\n"
f'printf "%s %s\\n" "{name}" "$*" >> "$FAKE_CALLS_LOG"\n'
"case \"$1\" in\n"
" -t|-c)\n"
" if [ -n \"$FAKE_CHECK_FAIL\" ]; then\n"
f' echo "fake {name}: configuration file test failed"\n'
" exit 1\n"
" fi\n"
" ;;\n"
" reload)\n"
" if [ -n \"$FAKE_RELOAD_FAIL\" ]; then exit 1; fi\n"
" ;;\n"
" status)\n"
' if [ -n "$FAKE_RELOAD_FAIL" ]; then echo "fake unit: reload failed"; fi\n'
" ;;\n"
"esac\n"
"exit 0\n",
encoding="utf-8")
script.chmod(0o755)
return stubs, log
def _run(tmp_path: Path, stubs: Path, log: Path, args: list[str], *,
check_fail: bool = False, reload_fail: bool = False,
agent_module: str | None = None
) -> tuple[subprocess.CompletedProcess[str], dict, list[str]]:
"""One helper invocation the way the operator runs it by hand: the real binary,
the fake tree, the fake proxy commands. Returns the process, the one JSON answer
line, and the recorded calls so far (the log is appended across invocations)."""
argv = [sys.executable, str(HELPER),
"--root", str(tmp_path / "root"),
"--agent-module", str(AGENT) if agent_module is None else agent_module,
"--state", str(tmp_path / "state.json"),
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
"--sink-address", "127.0.0.1:44450",
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy", *args]
env = {
"PATH": f"{stubs}{os.pathsep}{os.environ.get('PATH', '')}",
"FAKE_CALLS_LOG": str(log),
"FAKE_CHECK_FAIL": "1" if check_fail else "",
"FAKE_RELOAD_FAIL": "1" if reload_fail else "",
"HOME": str(tmp_path),
}
proc = subprocess.run(argv, capture_output=True, text=True, env=env, timeout=120)
lines = [line for line in proc.stdout.splitlines() if line.strip()]
answer: dict = json.loads(lines[-1]) if lines else {}
calls = log.read_text().splitlines() if log.exists() else []
return proc, answer, calls
def _serve(tmp_path: Path, tree: Path, allow_uid: str, request: bytes) -> tuple[int, str]:
"""`--serve` the way the unit does: the connected socket on fd 0/fd 1, one
request, one answer line. (The `tests/test_deploy_scripts.py` transport pin,
re-used for the wire-body gates the overview demands in this file.)"""
child, parent = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
proc = subprocess.Popen(
[sys.executable, str(HELPER), "--serve",
"--allow-uid", allow_uid,
"--agent-module", str(AGENT),
"--root", str(tree),
"--state", str(tmp_path / "serve-state.json"),
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
"--sink-address", "127.0.0.1:44450",
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy"],
stdin=child.fileno(), stdout=child.fileno(),
stderr=subprocess.PIPE, close_fds=True)
parent.sendall(request)
answer = parent.recv(65536).decode("utf-8").strip()
parent.close()
err = proc.stderr.read().decode() if proc.stderr is not None else ""
proc.wait(timeout=60)
child.close()
if proc.returncode not in (0, 1):
raise AssertionError(
f"the helper exited {proc.returncode} instead of answering: {answer!r} {err}")
return proc.returncode, answer
def _load(name: str, path: Path) -> types.ModuleType:
"""Import a stdlib module by file path, the way the helper imports the agent."""
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None, f"no loader for {path}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _snapshot(tree: Path) -> dict[Path, bytes]:
return {path: path.read_bytes() for path in sorted(tree.rglob("*")) if path.is_file()}
def _sink(tree: Path) -> Path:
return tree / "etc/nginx/stream.d/00-dashboards-sink.conf"
@pytest.fixture
def tree(tmp_path: Path) -> Path:
return _tree(tmp_path)
@pytest.fixture
def stubs(tmp_path: Path) -> tuple[Path, Path]:
return _stubs(tmp_path)
def test_disabling_one_route_changes_exactly_one_line(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
"""The whole write, against the fakes: one hunk, one line, every other byte
identical — asserted on the bytes, not on a count. The reload is the one verb
the file executes, and the state sidecar names the write."""
stubs_dir, log = stubs
hap = tree / "etc/haproxy/haproxy.cfg"
before_files = _snapshot(tree)
before = hap.read_bytes()
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer == {"ok": True, "applied": True, "validated": True,
"reloaded": True, "status": "", "state": "off"}, answer
after = hap.read_bytes()
b, a = before.decode("utf-8").splitlines(), after.decode("utf-8").splitlines()
assert len(b) == len(a), "the edit is in place: one line, never an add or a remove"
changed = [i for i in range(len(b)) if b[i] != a[i]]
assert len(changed) == 1, f"exactly one line changed byte for byte, nothing else: {changed}"
index = changed[0]
assert b[index] == " server iperf3 web.reeselink.com:5201 check inter 5s"
assert a[index] == b[index] + " #off iperf3", (
"the marker is the whole trailing comment; the original stands verbatim before it")
for path, blob in before_files.items():
if path != hap:
assert path.read_bytes() == blob, f"{path} moved when one haproxy line was marked"
assert "systemctl reload haproxy" in calls, "the one verb, for the one unit"
assert "systemctl reload nginx" not in calls
assert not [c for c in calls if "status" in c], (
f"a successful reload never asks the unit for its status: {calls}")
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
assert set(state) == {HAPROXY_ROUTE}
row = state[HAPROXY_ROUTE]
assert row["action"] == "disable"
assert row["file"] == "/etc/haproxy/haproxy.cfg"
assert row["written"] == b[index] + " #off iperf3"
assert row["original"] == b[index]
assert row["sha256"] == hashlib.sha256(after).hexdigest()
def test_the_vhost_arm_comments_the_proxy_pass_line_and_restores(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""The second arm: the vhost's `proxy_pass` is commented the same way, the
sibling vhost on another port is untouched, and the re-enable is byte-identical."""
stubs_dir, log = stubs
http = tree / "etc/nginx/conf.d/http.conf"
before = http.read_bytes()
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", VHOST_ROUTE, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer["state"] == "off"
lines = http.read_text().splitlines()
assert " proxy_pass http://nextcloud.reeselink.com:11000; #off nextcloud-11000" in lines
assert " proxy_pass http://nextcloud.reeselink.com:11001;" in lines, (
"the 8443 vhost's own line is a different fact; it does not move")
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", VHOST_ROUTE, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer["state"] == "on"
assert http.read_bytes() == before, "the re-enable restores the file byte for byte"
assert "systemctl reload nginx" in calls
assert "systemctl reload haproxy" not in calls
def test_a_disabled_map_entry_points_at_the_sink_and_keeps_its_original(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""The third arm: the map entry is **rewritten to the sink** (commenting it would
fall through to the map's `default` and mis-route the domain), the sink file is
created on first need with its header, and the re-enable — addressed by the sink
value the entry carries while off — restores the file byte-identical and removes
the sink the helper wrote. A `dry_run` writes nothing at all, not even the sink."""
stubs_dir, log = stubs
stream = tree / "etc/nginx/stream.d/stream.conf"
sink = _sink(tree)
original_line = " brain.reeseapps.com reeseapps-brain-of-reese.reeselink.com:443;"
before = stream.read_bytes()
# the dry run: the unified diff and nothing else — no file, no checker, no reload
proc, answer, calls = _run(tmp_path, stubs_dir, log, ["disable", "--id", MAP_ROUTE])
assert proc.returncode == 0, proc.stderr
assert answer["ok"] is True and answer["applied"] is False
assert answer["changed"] is True and answer["dry_run"] is True
assert f"-{original_line}" in answer["diff"]
assert "+ brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in answer["diff"]
assert stream.read_bytes() == before, "a dry run writes nothing"
assert not sink.exists(), "a dry run writes no file at all (not even the sink's)"
assert calls == [], f"a dry run neither validates nor reloads: {calls}"
assert not (tmp_path / "state.json").exists()
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", MAP_ROUTE, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer["state"] == "off"
assert sink.is_file(), "the sink is created on first need, before the checker runs"
sink_text = sink.read_text(encoding="utf-8")
assert sink_text.splitlines()[0] == SINK_HEADER
assert "listen 127.0.0.1:44450;" in sink_text
assert "proxy_pass" not in sink_text, "the block, not a vhost, is what the page's `sink_endpoint` recognises"
lines = stream.read_text().splitlines()
assert " brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in lines
assert original_line not in lines
# the enable is addressed by the sink value the entry carries while off
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", MAP_SINK_ID, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer["state"] == "on"
assert stream.read_bytes() == before, "the re-enable restores the file byte-identical to the pre-drill capture"
assert not sink.exists(), "the helper removes the sink it wrote once nothing points at it"
assert json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) == {}
assert "systemctl reload nginx" in calls
def test_the_map_default_is_refused_and_nothing_is_written(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""The map's `default` is the fallback for every unmapped SNI, not a service:
the id that addresses it is refused at the wire, before a byte moves or a
checker runs."""
stubs_dir, log = stubs
before = _snapshot(tree)
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", MAP_DEFAULT, "--apply"])
assert proc.returncode == 1
assert answer == {"ok": False, "applied": False, "refused": "map-default"}
assert _snapshot(tree) == before, "a refused write writes nothing"
assert calls == [], f"a wire-level refusal reaches neither the checker nor the unit: {calls}"
def test_an_unknown_id_is_refused(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
stubs_dir, log = stubs
before = _snapshot(tree)
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", "haproxy:9999->10.0.0.9:9999", "--apply"])
assert proc.returncode == 1
assert answer == {"ok": False, "detail": "not-found"}
assert _snapshot(tree) == before
assert calls == []
def test_disabling_a_disabled_route_changes_nothing_and_reloads_nothing(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""Step 2: a route that is already off is an answer, not a write — no file, no
checker, no reload. The fake `systemctl` log stays empty on the second call."""
stubs_dir, log = stubs
hap = tree / "etc/haproxy/haproxy.cfg"
_proc, _answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"])
after_first = hap.read_bytes()
seen = len(calls)
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer == {"ok": True, "applied": False, "changed": False,
"id": HAPROXY_ROUTE, "state": "off"}
assert hap.read_bytes() == after_first
assert len(calls) == seen, f"an already-off route changes nothing and reloads nothing: {calls[seen:]}"
def test_a_failing_validator_restores_the_previous_bytes(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
"""Step 5: the checker says no, the previous bytes go back byte-identical,
nothing reloads, and no state is recorded — the answer carries the checker's
own first line and the non-zero exit says it out."""
stubs_dir, log = stubs
hap = tree / "etc/haproxy/haproxy.cfg"
before = hap.read_bytes()
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"],
check_fail=True)
assert proc.returncode == 1
assert answer["ok"] is False and answer["applied"] is False
assert answer["validated"] is False and answer["restored"] is True
assert answer["detail"] == "fake haproxy: configuration file test failed"
assert hap.read_bytes() == before, "the restore is byte-identical"
assert [c for c in calls if c.startswith("haproxy -c")] == [
f"haproxy -c -f {tmp_path / 'root' / 'etc/haproxy/haproxy.cfg'}"], calls
assert not [c for c in calls if c.startswith("systemctl")], (
f"a failed validator reloads nothing: {calls}")
assert not (tmp_path / "state.json").exists(), "a failed write records no state"
def test_a_failing_reload_says_so_and_leaves_the_file_applied(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""Step 6: the config is correct and the reload is not — the file stays
applied, the answer says so with the unit's own status line, and the state is
still recorded (the write happened; the reload is the operator's next action)."""
stubs_dir, log = stubs
hap = tree / "etc/haproxy/haproxy.cfg"
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"],
reload_fail=True)
assert proc.returncode == 0, "a reload failure is an answered call, not a crash"
assert answer["ok"] is True and answer["applied"] is True
assert answer["validated"] is True and answer["reloaded"] is False
assert answer["status"] == "fake unit: reload failed"
assert " #off iperf3" in hap.read_text(), "the applied file stays applied"
assert "systemctl reload haproxy" in calls
assert "systemctl status haproxy" in calls, "the answer carries the unit's own line"
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
assert HAPROXY_ROUTE in state, "the write happened; the state says so"
def test_reload_is_never_a_restart() -> None:
"""The only verb this file executes is `systemctl reload <unit>`: a restart
severs every live connection through the fleet's only ingress. Pinned at the
call position (the docstring's negation is backticked, never an argv)."""
src = HELPER.read_text(encoding="utf-8")
verbs = re.findall(r'\["systemctl", "(\w+)"', src)
assert verbs == ["reload", "status"], f"the only systemctl verbs: {verbs}"
assert not re.search(r'["\']restart["\']', src), (
"no quoted `restart` anywhere: the docstring refuses it in words, the argv never carries it")
def test_the_helper_imports_the_agents_parser_and_refuses_without_it(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""The parser is the agent's, imported by file path: a helper that cannot
import it refuses to run at all, names the path, and writes nothing — two
parsers reading one config are two opinions about the same file."""
stubs_dir, log = stubs
before = _snapshot(tree)
missing = tmp_path / "no-such-agent.py"
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"],
agent_module=str(missing))
assert proc.returncode == 1
assert answer == {"ok": False,
"detail": f"cannot import the agent module {missing}: not a file"}
assert _snapshot(tree) == before
assert calls == []
def test_state_is_0600_and_drift_is_refused(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
"""The sidecar's mode is its birth mode, and `enable` restores only what this
helper wrote: a hand-edited line is `drift` — a refusal with no byte change,
never a guess. The other arm is a line this helper never wrote (no state row)."""
stubs_dir, log = stubs
hap = tree / "etc/haproxy/haproxy.cfg"
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", HAPROXY_ROUTE, "--apply"])
state_path = tmp_path / "state.json"
assert stat.S_IMODE(state_path.stat().st_mode) == 0o600, "the mode is the birth mode"
off_line = " server iperf3 web.reeselink.com:5201 check inter 5s #off iperf3"
assert off_line in hap.read_text(encoding="utf-8")
edited = hap.read_text(encoding="utf-8").replace(
off_line, " server iperf3 web.reeselink.com:5201 check inter 5s backup")
hap.write_text(edited, encoding="utf-8")
seen = len(_calls)
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", HAPROXY_ROUTE, "--apply"])
assert proc.returncode == 1
assert answer["ok"] is False and answer["applied"] is False and answer["drift"] is True
assert answer["detail"] == "the line was edited after the helper wrote it"
assert hap.read_text(encoding="utf-8") == edited, "drift is refused, not guessed at: no byte moves"
assert len(calls) == seen, f"a drift refusal touches neither the checker nor the unit: {calls[seen:]}"
# the other arm: a line this helper never wrote (no state row at all)
(tmp_path / "state.json").unlink()
vhost = tree / "etc/nginx/conf.d/http.conf"
before_vhost = vhost.read_bytes()
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", VHOST_ROUTE, "--apply"])
assert proc.returncode == 1
assert answer["ok"] is False and answer["drift"] is True
assert answer["detail"] == "no state row: this helper did not write the line"
assert vhost.read_bytes() == before_vhost
def test_serve_refuses_a_peer_that_is_not_the_agent_uid(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
"""The second look, in the fake-peer test: a connected caller whose uid is not
`--allow-uid` is refused before the body is read — the gate never opens for a
caller it was not given."""
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("running as root: the foreign-uid arm needs a foreign uid")
code, answer = _serve(tmp_path, tree, "root",
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
assert code == 1
body = json.loads(answer)
assert body["ok"] is False
# the refusal names the caller's own uid and the user it was not: the gate
# opened for no one, and the dialer gets the reason, not a timeout
assert body["detail"] == f"peer: uid {os.geteuid()} is not root", (
f"the refusal names the uid and the user: {body}")
def test_serve_accepts_exactly_id_action_dry_run(tree: Path, stubs: tuple[Path, Path],
tmp_path: Path) -> None:
"""The wire's gate, from the accepted side: a named peer's `show` is answered,
a wider body is refused with the wire's own sentence, and a `dry_run` write
answers the diff without writing anything (not even the sink's file)."""
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("running as root: the accept arm needs the caller to be the allowed uid")
me = pwd.getpwuid(os.geteuid()).pw_name
code, answer = _serve(tmp_path, tree, me,
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
assert code == 0
body = json.loads(answer)
assert body == {"ok": True, "id": HAPROXY_ROUTE, "state": "on",
"file": "/etc/haproxy/haproxy.cfg"}, body
code, answer = _serve(tmp_path, tree, me,
(json.dumps({"id": HAPROXY_ROUTE, "action": "show",
"command": "reboot"}) + "\n").encode())
assert code == 1
body = json.loads(answer)
assert body == {"ok": False, "detail": "body: exactly {id, action, dry_run}"}
hap = tree / "etc/haproxy/haproxy.cfg"
before = hap.read_bytes()
code, answer = _serve(tmp_path, tree, me,
(json.dumps({"id": HAPROXY_ROUTE, "action": "disable",
"dry_run": True}) + "\n").encode())
assert code == 0
body = json.loads(answer)
assert body["ok"] is True and body["applied"] is False and body["dry_run"] is True
assert "diff" in body
assert hap.read_bytes() == before, "a dry run over the wire writes nothing"
assert not _sink(tree).exists()
def test_no_shell_and_no_path_from_the_wire() -> None:
"""The structural pins (the `auth.py`/`certs.py` precedent): every subprocess
call is a fixed argv list — no `shell=True` — and the wire takes an id, never a
path, a line number or a pattern; `--serve` accepts exactly `{id, action,
dry_run}` and every other key is a refusal."""
src = HELPER.read_text(encoding="utf-8")
assert not re.search(r"subprocess\.\w+\([^)]*shell\s*=", src, re.S), (
"no `shell=` at a call position (the docstring's negation is backticked; "
"every call is a fixed argv list — `shell=True` included)")
flags = re.findall(r'add_argument\(\s*"([^"]+)"', src)
assert flags, "the flags must be there for the pin to mean anything"
assert not {flag for flag in flags if flag in ("--file", "--line", "--pattern")}, (
f"the wire takes an id, never a path/line/pattern; the flags are {flags}")
assert 'set(body) - {"id", "action", "dry_run"}' in src, (
"the wire's gate: anything wider than {id, action, dry_run} is a refusal")
assert '"body: exactly {id, action, dry_run}"' in src
def test_the_sink_file_is_left_alone_when_someone_elses_wrote_it(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""An operator's file at the sink's path is not the helper's to overwrite:
the `map` disable refuses before the config write, and the file is untouched."""
stubs_dir, log = stubs
sink = _sink(tree)
operator_file = "# an operator's file\nserver {\n listen 127.0.0.1:44451;\n}\n"
sink.write_text(operator_file, encoding="utf-8")
before = _snapshot(tree)
proc, answer, calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", MAP_ROUTE, "--apply"])
assert proc.returncode == 1
assert answer == {"ok": False,
"detail": "sink-file-not-ours: /etc/nginx/stream.d/00-dashboards-sink.conf"}
assert _snapshot(tree) == before, "the refusal writes nothing (the operator's file is byte-identical)"
assert calls == [], f"the refusal happens before the checker and the unit: {calls}"
def test_the_sink_is_removed_only_when_it_is_empty_of_our_routes(
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
"""Two routes off: two lines carry the sink value, and enabling by that id is
`ambiguous` — two facts, never a guess, because no line number crosses the
wire. After the operator restores one line by hand, the enable succeeds and the
sink goes with it. The remains-arm is pinned on the helper's own function: a
map entry still pointing at the sink keeps the file (the wire cannot reach that
arm — the ambiguity above is why)."""
stubs_dir, log = stubs
stream = tree / "etc/nginx/stream.d/stream.conf"
sink = _sink(tree)
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", MAP_ROUTE, "--apply"])
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
["disable", "--id", IMMICH_ROUTE, "--apply"])
assert sink.is_file()
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", MAP_SINK_ID, "--apply"])
assert proc.returncode == 1
assert answer["ok"] is False
assert "ambiguous" in answer["detail"], f"two lines at the sink are two facts: {answer}"
text = stream.read_text(encoding="utf-8")
text = text.replace(" immich.reeseapps.com 127.0.0.1:44450; #off immich.reeseapps.com",
IMMICH_ORIGINAL)
stream.write_text(text, encoding="utf-8")
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
["enable", "--id", MAP_SINK_ID, "--apply"])
assert proc.returncode == 0, proc.stderr
assert answer["state"] == "on"
assert not sink.exists(), "the helper removes the sink it wrote once no route still points at it"
# the remains-arm, on the helper's own function: a route still points at the sink
helper = _load("routectl_under_test", HELPER)
agent = _load("agent_under_test", AGENT)
ctx = argparse.Namespace(root=str(tree),
haproxy_config="/etc/haproxy/haproxy.cfg",
nginx_config="/etc/nginx/nginx.conf",
state_file=str(tmp_path / "sink-state.json"),
sink_conf="/etc/nginx/stream.d/00-dashboards-sink.conf",
sink_address="127.0.0.1:44450",
nginx_unit="nginx", haproxy_unit="haproxy",
actor="manual", allow_uid="nobody")
write = helper.Write(ctx, agent, "nginx", 443, "127.0.0.1", 44450, MAP_SINK_ID)
sink.write_text(f"{SINK_HEADER}\nserver {{\n listen 127.0.0.1:44450;\n}}\n",
encoding="utf-8")
documents = {"/etc/nginx/stream.d/stream.conf":
"map $ssl_preread_server_name $targetBackend {\n"
" default 127.0.0.1:4443;\n"
" a.reeseapps.com 127.0.0.1:44450;\n"
"}\n"}
write.remove_sink_if_empty(documents)
assert sink.is_file(), "a route still points at the sink: it stays"
def test_the_helper_parses_on_python_3_10() -> None:
"""The helper is installed beside the agent and must run on a host's Python
3.10: the floor is in the header, and the file carries no 3.11+ syntax."""
src = HELPER.read_text(encoding="utf-8")
assert "# requires: python >=3.10" in src
ast.parse(src, feature_version=(3, 10))