diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index ae915a5..8d415bf 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red from fastapi.templating import Jinja2Templates from psyc import db, log -from psyc.lines import discovery, federation, network_view, pulse, translog +from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog from psyc.result import Err @@ -39,6 +39,11 @@ _PUBLIC_NETWORK_TTL = 60.0 _EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None} _EXPLORE_TTL = 60.0 +# Sanitized docker topology cache. The build call hits the docker-socket-proxy +# sidecar; polled peer admin pages mustn't re-trigger that on every poke. +_TOPOLOGY_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None} +_TOPOLOGY_TTL = 60.0 + # Headers we slap on every public endpoint so other psyc nodes' explore # pages can fetch them cross-origin from the browser. @@ -77,6 +82,16 @@ def _cached_public_network() -> Dict[str, Any]: return _PUBLIC_NETWORK_CACHE["payload"] +def _cached_topology() -> Dict[str, Any]: + """Cached sanitized docker topology — same poll-load pattern as the feed.""" + now = time.time() + if _TOPOLOGY_CACHE["payload"] is None or (now - _TOPOLOGY_CACHE["ts"]) > _TOPOLOGY_TTL: + export = topology_export.build_export() + _TOPOLOGY_CACHE["payload"] = export.model_dump(mode="json") + _TOPOLOGY_CACHE["ts"] = now + return _TOPOLOGY_CACHE["payload"] + + def _cached_explore(domain: Optional[str]) -> Dict[str, Any]: """Cached explore payload. Re-uses the cache when the host domain matches. @@ -257,6 +272,17 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: """ return _public_json(_cached_public_network()) + @app.get("/federation/topology") + def federation_topology_public() -> JSONResponse: + """Sanitized docker topology — public, for peer-side display. + + Whitelist-only: container names + images + state + network names. No + env vars, no volume mounts, no IPs/MACs/gateways, no labels. CORS open + so a peer's `/admin/federation/network` page can fetch it from the + browser and render every node's containers alongside its own. + """ + return _public_json(_cached_topology()) + # ---------- public vouches + transparency log -------------------- @app.get("/federation/vouches") diff --git a/tests/test_topology_export.py b/tests/test_topology_export.py index c31f665..02b7c2a 100644 --- a/tests/test_topology_export.py +++ b/tests/test_topology_export.py @@ -1,24 +1,25 @@ -"""Topology export — whitelist sanitization unit tests. +"""Topology export — whitelist sanitization + endpoint contract. The big invariant: nothing from docker_view.topology() escapes that isn't in the Pydantic schema. We assert via model_fields introspection AND via a JSON-dump scan over a fixture that contains every dangerous field. - -The /federation/topology endpoint contract lives in the sibling tests added -alongside it; this module covers the builder + the sanitizer in isolation. """ from __future__ import annotations import json -from typing import Any, Dict +from typing import Any, Dict, List from unittest.mock import patch import pytest +from fastapi import FastAPI +from fastapi.templating import Jinja2Templates +from fastapi.testclient import TestClient from sqlalchemy import create_engine +from starlette.middleware.sessions import SessionMiddleware from psyc import db -from psyc.cockpit import docker_view +from psyc.cockpit import docker_view, federation_routes from psyc.lines import federation, topology_export from psyc.lines.topology_export import ( TopologyContainer, @@ -50,6 +51,14 @@ def fed_dir(tmp_path, monkeypatch): yield d +@pytest.fixture(autouse=True) +def reset_topology_cache(): + if hasattr(federation_routes, "_TOPOLOGY_CACHE"): + federation_routes._TOPOLOGY_CACHE["payload"] = None + federation_routes._TOPOLOGY_CACHE["ts"] = 0.0 + yield + + # ---------- fixture data: hostile docker_view output -------------------- # This payload has every leaky field docker_view *could* surface, plus @@ -243,3 +252,62 @@ def test_build_export_caps_at_max_containers(fresh_db, fed_dir, monkeypatch): monkeypatch.setattr(docker_view, "topology", lambda: fake) out = build_export() assert out.container_count == topology_export.MAX_CONTAINERS + + +# ---------- HTTP endpoint ----------------------------------------------- + +def _mk_app() -> FastAPI: + app = FastAPI() + app.add_middleware(SessionMiddleware, secret_key="test-secret") + from pathlib import Path as _Path + here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates" + templates = Jinja2Templates(directory=str(here)) + federation_routes.register(app, templates) + return app + + +def test_federation_topology_endpoint_returns_json_with_cors(fresh_db, fed_dir, monkeypatch): + monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY) + client = TestClient(_mk_app()) + r = client.get("/federation/topology") + assert r.status_code == 200 + assert r.headers.get("access-control-allow-origin") == "*" + data = r.json() + # Schema check. + for key in ("node_fingerprint", "generated_at", "host_name", + "container_count", "network_count", "containers", "networks"): + assert key in data + assert data["container_count"] == 2 + assert len(data["containers"]) == 2 + # No leaks in the wire response either. + blob = r.text + for forbidden in _FORBIDDEN_STRINGS: + assert forbidden not in blob, f"leak via endpoint: {forbidden!r}" + + +def test_federation_topology_endpoint_resilient_when_docker_unavailable(fresh_db, fed_dir, monkeypatch): + def boom(): + raise docker_view.DockerProxyError("proxy down") + monkeypatch.setattr(docker_view, "topology", boom) + client = TestClient(_mk_app()) + r = client.get("/federation/topology") + assert r.status_code == 200 + data = r.json() + assert data["container_count"] == 0 + assert data["containers"] == [] + + +def test_federation_topology_cache_short_circuits_repeated_calls(fresh_db, fed_dir, monkeypatch): + """Within TTL, a second hit must not re-call docker_view.""" + calls = {"n": 0} + + def counted(): + calls["n"] += 1 + return _LEAKY_TOPOLOGY + + monkeypatch.setattr(docker_view, "topology", counted) + client = TestClient(_mk_app()) + r1 = client.get("/federation/topology") + r2 = client.get("/federation/topology") + assert r1.status_code == 200 and r2.status_code == 200 + assert calls["n"] == 1, "cache should suppress the second docker_view call"