stage-topo-b topology-export: /federation/topology endpoint + CORS cache

This commit is contained in:
m17hr1l
2026-06-07 01:56:09 +02:00
parent a8216d00ef
commit 367f17a013
2 changed files with 101 additions and 7 deletions

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from psyc import db, log 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 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_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
_EXPLORE_TTL = 60.0 _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 # Headers we slap on every public endpoint so other psyc nodes' explore
# pages can fetch them cross-origin from the browser. # 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"] 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]: def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
"""Cached explore payload. Re-uses the cache when the host domain matches. """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()) 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 -------------------- # ---------- public vouches + transparency log --------------------
@app.get("/federation/vouches") @app.get("/federation/vouches")

View File

@@ -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 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 in the Pydantic schema. We assert via model_fields introspection AND via a
JSON-dump scan over a fixture that contains every dangerous field. 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 from __future__ import annotations
import json import json
from typing import Any, Dict from typing import Any, Dict, List
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from starlette.middleware.sessions import SessionMiddleware
from psyc import db 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 import federation, topology_export
from psyc.lines.topology_export import ( from psyc.lines.topology_export import (
TopologyContainer, TopologyContainer,
@@ -50,6 +51,14 @@ def fed_dir(tmp_path, monkeypatch):
yield d 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 -------------------- # ---------- fixture data: hostile docker_view output --------------------
# This payload has every leaky field docker_view *could* surface, plus # 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) monkeypatch.setattr(docker_view, "topology", lambda: fake)
out = build_export() out = build_export()
assert out.container_count == topology_export.MAX_CONTAINERS 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"