Skip to content

Commit 688a98a

Browse files
committed
fix: enforce status transport boundaries
1 parent 9f7a924 commit 688a98a

7 files changed

Lines changed: 56 additions & 11 deletions

File tree

‎docs/STATUS-REPORTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ All times are Unix seconds. Commit values are empty when unavailable. Receiver v
3333

3434
## Authentication
3535

36-
Each controller receives a unique random HMAC key. A key must contain 32-128 bytes and be owned by root with mode `0600` on the controller. The receiver keeps a separate copy owned by its service account with mode `0600`.
36+
Each controller receives a unique random HMAC key. A key must contain 32-128 bytes and be owned by root with mode `0600` on the controller. The receiver keeps a separate copy owned by its service account with mode `0600`. Key files are read byte-for-byte, including leading or trailing whitespace; copy the same raw bytes to both sides.
3737

3838
Every report includes:
3939

‎host/systemd/ci-fleet-health.service‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ Type=oneshot
88
User=root
99
WorkingDirectory=/opt/ci-fleet/manager/current
1010
EnvironmentFile=/etc/ci-fleet/ci-fleet.env
11+
Environment=CI_FLEET_HEALTH_DELIVER_STATUS=1
1112
ExecStart=/opt/ci-fleet/manager/current/scripts/healthcheck.sh
1213
SuccessExitStatus=1

‎scripts/health.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ def _send_status(
579579
return 1 if values.get("CI_FLEET_HEALTH_HEARTBEAT_URL") else 0
580580
try:
581581
parsed = urllib.parse.urlsplit(url)
582+
parsed.port
582583
except ValueError:
583584
return 1
584585
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/v1/status" or parsed.query or parsed.fragment:
@@ -592,7 +593,7 @@ def _send_status(
592593
expected_owner = os.getuid() if os.environ.get("CI_FLEET_TESTING") == "1" else 0
593594
if info.st_uid != expected_owner or stat.S_IMODE(info.st_mode) & 0o077:
594595
return 1
595-
key = path.read_bytes().strip()
596+
key = path.read_bytes()
596597
except OSError:
597598
return 1
598599
if not 32 <= len(key) <= 128:
@@ -611,7 +612,7 @@ def _send_status(
611612
transport = opener or urllib.request.build_opener(_NoRedirect).open
612613
with transport(request, timeout=10) as response:
613614
return 0 if 200 <= response.status < 300 else 1
614-
except OSError:
615+
except (OSError, ValueError):
615616
return 1
616617

617618

@@ -622,7 +623,7 @@ def _local(args: argparse.Namespace) -> int:
622623
report = evaluate(snapshot, thresholds_from(values))
623624
now = int(time.time())
624625
report["timestamp"] = now
625-
delivery = _send_status(values, build_status_report(snapshot, report, generated_at=now), now=now)
626+
delivery = _send_status(values, build_status_report(snapshot, report, generated_at=now), now=now) if values.get("CI_FLEET_HEALTH_DELIVER_STATUS") == "1" else 0
626627
if delivery:
627628
report["checks"].append({"id": "status_delivery", "status": "warning"})
628629
if report["exit_code"] == 0:

‎scripts/status_receiver.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import json
99
import os
1010
import re
11+
import socket
1112
import sqlite3
1213
import threading
1314
import time
@@ -136,7 +137,7 @@ def integer(value: Any, minimum: int = 0) -> bool:
136137
def number(value: Any, minimum: float = 0) -> bool:
137138
return isinstance(value, (int, float)) and not isinstance(value, bool) and value >= minimum and value < float("inf")
138139

139-
if not isinstance(report, dict) or report.get("schema_version") != 1:
140+
if not isinstance(report, dict) or type(report.get("schema_version")) is not int or report["schema_version"] != 1:
140141
raise StatusError(400, "unsupported_schema")
141142
root_keys = {"schema_version", "controller", "configuration", "reconciliation", "drift", "process", "timers", "runners", "metrics", "docker", "error", "generated_at"}
142143
if set(report) != root_keys:
@@ -247,6 +248,7 @@ def __init__(self, *args: Any, max_requests: int = 32, request_timeout: int = 15
247248
self.max_requests = max_requests
248249
self.request_timeout = request_timeout
249250
self._slots = threading.BoundedSemaphore(max_requests)
251+
self._deadlines: dict[Any, threading.Timer] = {}
250252
super().__init__(*args, **kwargs)
251253

252254
def process_request(self, request: Any, client_address: Any) -> None:
@@ -255,15 +257,32 @@ def process_request(self, request: Any, client_address: Any) -> None:
255257
return
256258
try:
257259
request.settimeout(self.request_timeout)
260+
timer = threading.Timer(self.request_timeout, self._expire_request, (request,))
261+
timer.daemon = True
262+
self._deadlines[request] = timer
263+
timer.start()
258264
super().process_request(request, client_address)
259265
except Exception:
266+
timer = self._deadlines.pop(request, None)
267+
if timer:
268+
timer.cancel()
260269
self._slots.release()
261270
raise
262271

272+
@staticmethod
273+
def _expire_request(request: Any) -> None:
274+
try:
275+
request.shutdown(socket.SHUT_RDWR)
276+
except OSError:
277+
pass
278+
263279
def process_request_thread(self, request: Any, client_address: Any) -> None:
264280
try:
265281
super().process_request_thread(request, client_address)
266282
finally:
283+
timer = self._deadlines.pop(request, None)
284+
if timer:
285+
timer.cancel()
267286
self._slots.release()
268287

269288

@@ -324,11 +343,13 @@ def log_message(self, format: str, *args: Any) -> None:
324343
return _BoundedHTTPServer((bind, port), Handler)
325344

326345

327-
def _read_secret(path: Path) -> bytes:
346+
def _read_secret(path: Path, *, textual: bool = False) -> bytes:
328347
info = path.stat()
329348
if info.st_uid != os.getuid() or info.st_mode & 0o077:
330349
raise ValueError(f"secret must be owned by the receiver user with mode 0600: {path}")
331-
value = path.read_bytes().strip()
350+
value = path.read_bytes()
351+
if textual:
352+
value = value.strip()
332353
if not 32 <= len(value) <= 128:
333354
raise ValueError(f"secret must contain 32-128 bytes: {path}")
334355
return value
@@ -349,7 +370,7 @@ def load_auth_config(path: Path) -> tuple[dict[str, bytes], str]:
349370
keys[controller] = _read_secret(resolve(key_file))
350371
if not keys or not isinstance(value["read_token_file"], str):
351372
raise ValueError("auth config requires at least one controller and a read token")
352-
return keys, _read_secret(resolve(value["read_token_file"])).decode()
373+
return keys, _read_secret(resolve(value["read_token_file"]), textual=True).decode()
353374

354375

355376
def main() -> int:

‎scripts/test-install-worker-controller.sh‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ set -Eeuo pipefail
33

44
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
55
health_timer=$repo_root/host/systemd/ci-fleet-health.timer
6+
health_service=$repo_root/host/systemd/ci-fleet-health.service
67
grep -Fqx 'OnActiveSec=2min' "$health_timer" || { printf 'FAIL: health timer lacks activation-relative initial trigger\n' >&2; exit 1; }
78
! grep -Fq 'OnBootSec=' "$health_timer" || { printf 'FAIL: health timer initial trigger is boot-relative\n' >&2; exit 1; }
9+
grep -Fqx 'Environment=CI_FLEET_HEALTH_DELIVER_STATUS=1' "$health_service" || { printf 'FAIL: scheduled health does not enable status delivery\n' >&2; exit 1; }
810
tmp=$(mktemp -d)
911
trap 'rm -rf "$tmp"' EXIT
1012
fake_bin=$tmp/bin
@@ -496,7 +498,7 @@ warning_output=$tmp/warning-upgrade.out
496498
"$installer" --upgrade "${base_args[@]}" --ref "$warning_ref" >"$warning_output" 2>&1
497499
warning_upgrade=$(<"$warning_output")
498500
grep -Fq 'WARNING disk_root' <<<"$warning_upgrade" || fail 'warning health fixture did not produce a warning result'
499-
grep -Fq 'WARNING status_delivery' <<<"$warning_upgrade" || fail 'status reporting outage was not observable as a warning'
501+
if grep -Fq 'WARNING status_delivery' <<<"$warning_upgrade"; then fail 'ad-hoc reconciliation health check submitted duplicate status'; fi
500502
grep -Fq 'CONVERGED mode=upgrade' <<<"$warning_upgrade" || fail 'warning health result did not report convergence'
501503
grep -Fq "CI_FLEET_CONFIG_REF=$warning_ref" "$root/etc/ci-fleet/ci-fleet.env" || fail 'warning health result rolled back an otherwise healthy activation'
502504
rm -f "$root/etc/ci-fleet/monitoring.env"

‎scripts/test_health.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,14 @@ def opener(request, timeout):
345345
try:
346346
self.assertEqual(health._send_status(values, report, now=1_000, nonce="a" * 32, opener=opener), 0)
347347
self.assertIn("CI-Fleet-HMAC-SHA256", captured["headers"]["Authorization"])
348+
expected = health.sign_headers("example-ci-01", captured["body"], key.read_bytes(), timestamp=1_000, nonce="a" * 32)
349+
self.assertEqual(captured["headers"]["Authorization"], expected["Authorization"])
348350
original = copy.deepcopy(report)
349351
self.assertEqual(health._send_status(values, report, now=1_000, nonce="b" * 32, opener=lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError())), 1)
350352
self.assertEqual(report, original)
351353
self.assertEqual(health._send_status({"CI_FLEET_HEALTH_STATUS_URL": "http://unsafe.invalid"}, report), 1)
352354
self.assertEqual(health._send_status({"CI_FLEET_HEALTH_STATUS_URL": "https://[bad/v1/status"}, report), 1)
355+
self.assertEqual(health._send_status({"CI_FLEET_HEALTH_STATUS_URL": "https://status.example.invalid:bad/v1/status"}, report), 1)
353356
self.assertEqual(health._send_status({"CI_FLEET_HEALTH_HEARTBEAT_URL": "https://legacy.invalid"}, report), 1)
354357
self.assertIsNone(health._NoRedirect().redirect_request(None, None, 302, None, {}, None))
355358
finally:

‎scripts/test_status_receiver.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ def test_schema_compatibility_and_malformed_metrics(self) -> None:
177177
body, headers = self.signed(future)
178178
self.assert_status_error(400, "unsupported_schema", lambda: self.receiver.submit(body, headers, now=1_000))
179179

180+
boolean = valid_report()
181+
boolean["schema_version"] = True
182+
body, headers = self.signed(boolean, nonce="f" * 32)
183+
self.assert_status_error(400, "unsupported_schema", lambda: self.receiver.submit(body, headers, now=1_000))
184+
180185
malformed = valid_report()
181186
malformed["metrics"]["memory"]["available_bytes"] = -1
182187
body, headers = self.signed(malformed, nonce="b" * 32)
@@ -209,15 +214,16 @@ def test_time_retention_is_enforced_on_read(self) -> None:
209214
def test_receiver_restart_reloads_rotated_key(self) -> None:
210215
directory = Path(self.temporary.name)
211216
key_file, token_file, config_file = directory / "key", directory / "token", directory / "auth.json"
212-
key_file.write_bytes(b"a" * 32)
217+
raw_key = b"\n" + b"a" * 30 + b" "
218+
key_file.write_bytes(raw_key)
213219
token_file.write_bytes(b"r" * 32)
214220
config_file.write_text(json.dumps({"controllers": {"example-ci-01": "key"}, "read_token_file": "token"}))
215221
for path in (key_file, token_file, config_file):
216222
path.chmod(0o600)
217223
first, _ = status_receiver.load_auth_config(config_file)
218224
key_file.write_bytes(b"b" * 32)
219225
second, _ = status_receiver.load_auth_config(config_file)
220-
self.assertEqual((first["example-ci-01"], second["example-ci-01"]), (b"a" * 32, b"b" * 32))
226+
self.assertEqual((first["example-ci-01"], second["example-ci-01"]), (raw_key, b"b" * 32))
221227

222228
def test_http_post_and_read_only_api(self) -> None:
223229
server = status_receiver.create_server("127.0.0.1", 0, self.receiver)
@@ -261,6 +267,17 @@ def test_http_server_bounds_slow_clients(self) -> None:
261267
for _ in range(server.max_requests):
262268
server._slots.release()
263269

270+
expired = threading.Event()
271+
class SlowRequest:
272+
def shutdown(self, how: int) -> None:
273+
self.how = how
274+
expired.set()
275+
request = SlowRequest()
276+
timer = threading.Timer(0.01, server._expire_request, (request,))
277+
timer.start()
278+
self.assertTrue(expired.wait(1))
279+
self.assertEqual(request.how, status_receiver.socket.SHUT_RDWR)
280+
264281

265282
if __name__ == "__main__":
266283
unittest.main()

0 commit comments

Comments
 (0)