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:
+593
-41
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user