Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5bd5dbbb | ||
|
|
629bd1c340 | ||
|
|
cea094d66b | ||
|
|
01c146e21e | ||
|
|
8a0412b529 | ||
|
|
52edbf105c | ||
|
|
d60fef67ad | ||
|
|
fe643924c7 | ||
|
|
bccb3e0316 |
@@ -63,6 +63,7 @@ jobs:
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||
NEXT_PUBLIC_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
|
||||
deploy:
|
||||
name: Deploy to VM
|
||||
|
||||
@@ -33,6 +33,13 @@ SUMMARY_INTERVAL_MINUTES=15
|
||||
CORRELATION_WINDOW_HOURS=4
|
||||
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||
|
||||
# Browser origins allowed to call this API cross-origin (JSON list). The only
|
||||
# browser caller is the frontend's Archive page (GET /calls/search). Set this
|
||||
# to the exact origin the frontend is served from — scheme + host, no path.
|
||||
# Defaults to https://drb.cusano.net. A "*" entry works for local dev but is
|
||||
# logged as a probable misconfiguration and never gets a credentialed response.
|
||||
CORS_ORIGINS=["https://drb.cusano.net"]
|
||||
|
||||
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
|
||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||
# Generate with: openssl rand -hex 32
|
||||
|
||||
@@ -180,16 +180,18 @@ class Settings(BaseSettings):
|
||||
# between genuinely separate transmissions on a busy dispatch channel.
|
||||
duplicate_window_seconds: int = 10
|
||||
|
||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||
# Defaults to "*" for local development only.
|
||||
# Browser origins allowed to call this API cross-origin. The only browser
|
||||
# caller is the frontend's Archive page (GET /calls/search) — every other
|
||||
# page reads Firestore directly. The frontend is served on the BARE domain
|
||||
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
|
||||
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
|
||||
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
|
||||
#
|
||||
# Leaving this as "*" is not merely permissive: main.py turns OFF
|
||||
# allow_credentials when it sees a wildcard, because Starlette would
|
||||
# otherwise reflect each caller's origin back WITH
|
||||
# Access-Control-Allow-Credentials. So a production deployment that
|
||||
# forgets to set this gets a loud ERROR at startup and loses credentialed
|
||||
# cross-origin requests, rather than silently accepting every origin.
|
||||
cors_origins: list[str] = ["*"]
|
||||
# A "*" entry here still works for local dev but is refused a credentialed
|
||||
# response: main.py never enables allow_credentials (auth is a Bearer
|
||||
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
|
||||
# in a deployment so a forgotten override is visible.
|
||||
cors_origins: list[str] = ["https://drb.cusano.net"]
|
||||
|
||||
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
|
||||
# tier (transcription/correlation) transitions into or out of degraded
|
||||
|
||||
+24
-17
@@ -78,33 +78,40 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
||||
|
||||
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it
|
||||
# looks like. Starlette does not refuse the combination -- it reflects the
|
||||
# caller's Origin back and still sends Access-Control-Allow-Credentials: true,
|
||||
# so the effective policy becomes "any origin, with credentials", the opposite
|
||||
# of what a wildcard normally means. Rather than trust every deployment to
|
||||
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable.
|
||||
# The browser needs CORS to reach this API at all: the frontend's Archive page
|
||||
# calls GET /calls/search with Authorization + Content-Type headers, which
|
||||
# forces a preflight. Without this middleware the OPTIONS gets a bare 405 and
|
||||
# the fetch fails (#110). allow_origins is an explicit list -- never "*" in a
|
||||
# deployment -- so name every host the frontend is served from in CORS_ORIGINS.
|
||||
#
|
||||
# allow_credentials stays False on purpose: auth here is a Bearer header, not a
|
||||
# cookie, so credentialed CORS is never needed, and keeping it False is what
|
||||
# lets an explicit-origin allowlist work without Starlette's "*"-only
|
||||
# restriction. "*" + credentials is the dangerous pair (Starlette reflects the
|
||||
# caller's Origin back WITH Access-Control-Allow-Credentials: true); this code
|
||||
# cannot produce it because credentials are hard-off.
|
||||
def cors_allows_credentials(origins: list[str]) -> bool:
|
||||
"""False when any entry is a wildcard. Extracted so it can be tested
|
||||
without re-importing this module, which drags in every router."""
|
||||
return "*" not in origins
|
||||
"""Always False -- credentialed CORS is never enabled here (Bearer auth,
|
||||
not cookies). Kept as a named predicate so a future edit that wants to
|
||||
turn credentials on has to go through here and confront the "*" case.
|
||||
A wildcard entry would additionally be refused a credentialed response."""
|
||||
return False
|
||||
|
||||
|
||||
_cors_is_wildcard = not cors_allows_credentials(settings.cors_origins)
|
||||
_cors_is_wildcard = "*" in settings.cors_origins
|
||||
if _cors_is_wildcard:
|
||||
logger.error(
|
||||
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being "
|
||||
"DISABLED to avoid reflecting every caller's origin back with "
|
||||
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend "
|
||||
"origin(s) in production, e.g. [\"https://app.example.com\"]."
|
||||
"CORS_ORIGINS contains '*'. That is fine for local dev but is almost "
|
||||
"certainly a misconfigured deployment -- set CORS_ORIGINS to your "
|
||||
"frontend origin(s), e.g. [\"https://drb.cusano.net\"]."
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=not _cors_is_wildcard,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["authorization", "content-type"],
|
||||
allow_credentials=False,
|
||||
)
|
||||
|
||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
End-to-end CORS wiring for the one browser-facing REST surface.
|
||||
|
||||
The frontend's Archive page calls GET /calls/search with Authorization +
|
||||
Content-Type headers, which forces the browser to send a CORS preflight
|
||||
first. Before #110 that OPTIONS got a bare 405 with no Access-Control-*
|
||||
headers and the fetch failed with "TypeError: Failed to fetch". These
|
||||
tests drive the real app through TestClient so a regression in the
|
||||
middleware wiring (not just the helper) is caught.
|
||||
|
||||
TestClient is NOT used as a context manager on purpose: that would run the
|
||||
lifespan (mqtt_handler.connect(), the sweeper loops, dynsec bootstrap),
|
||||
none of which is needed here -- CORSMiddleware answers a preflight before
|
||||
routing or dependencies run.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
ALLOWED_ORIGIN = "https://drb.cusano.net"
|
||||
DISALLOWED_ORIGIN = "https://evil.example.com"
|
||||
|
||||
|
||||
def test_default_allowed_origin_matches_the_deployed_frontend():
|
||||
# The frontend is served on the bare domain (infra Caddyfile.j2), so the
|
||||
# default must allow exactly that origin without any env override.
|
||||
assert ALLOWED_ORIGIN in settings.cors_origins
|
||||
|
||||
|
||||
def test_preflight_for_calls_search_is_allowed():
|
||||
resp = client.options(
|
||||
"/calls/search",
|
||||
headers={
|
||||
"Origin": ALLOWED_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
|
||||
allow_methods = resp.headers.get("access-control-allow-methods", "").upper()
|
||||
assert "GET" in allow_methods
|
||||
# Bearer auth, not cookies -- credentials must never be advertised.
|
||||
assert "access-control-allow-credentials" not in resp.headers
|
||||
|
||||
|
||||
def test_preflight_from_disallowed_origin_gets_no_allow_origin():
|
||||
resp = client.options(
|
||||
"/calls/search",
|
||||
headers={
|
||||
"Origin": DISALLOWED_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") is None
|
||||
|
||||
|
||||
def test_simple_get_from_allowed_origin_is_annotated():
|
||||
# Even a non-preflight GET must carry Access-Control-Allow-Origin or the
|
||||
# browser hides the response body from the page.
|
||||
resp = client.get("/health", headers={"Origin": ALLOWED_ORIGIN})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
|
||||
@@ -5,8 +5,9 @@ Starlette does not reject `allow_origins=["*"]` combined with
|
||||
`allow_credentials=True`. It reflects the caller's Origin back in
|
||||
Access-Control-Allow-Origin and still sends
|
||||
Access-Control-Allow-Credentials: true, so the effective policy is the
|
||||
opposite of what a wildcard usually means. main.py defuses that by turning
|
||||
credentials off whenever it sees a wildcard; these tests hold it to that.
|
||||
opposite of what a wildcard usually means. main.py never enables
|
||||
credentials at all (auth is a Bearer header, not a cookie), which makes
|
||||
that pair unrepresentable; these tests hold it to that.
|
||||
|
||||
The policy lives in a pure function so it can be exercised directly --
|
||||
reloading app.main to vary settings drags every router back through import
|
||||
@@ -28,11 +29,11 @@ def test_wildcard_among_real_origins_still_disables_credentials():
|
||||
assert cors_allows_credentials(["https://app.example.com", "*"]) is False
|
||||
|
||||
|
||||
def test_named_origins_keep_credentials():
|
||||
# Naming your origins is how you ask for credentialed requests, so a
|
||||
# correctly configured deployment must not be penalised.
|
||||
assert cors_allows_credentials(["https://app.example.com"]) is True
|
||||
assert cors_allows_credentials([]) is True
|
||||
def test_credentials_never_enabled_even_for_named_origins():
|
||||
# Auth here is a Bearer header, not a cookie, so credentialed CORS is
|
||||
# never needed. The predicate is hard-off regardless of the origin list.
|
||||
assert cors_allows_credentials(["https://app.example.com"]) is False
|
||||
assert cors_allows_credentials([]) is False
|
||||
|
||||
|
||||
def test_the_app_actually_mounted_that_policy():
|
||||
@@ -42,6 +43,7 @@ def test_the_app_actually_mounted_that_policy():
|
||||
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
|
||||
)
|
||||
assert opts is not None, "CORSMiddleware is not mounted at all"
|
||||
assert opts["allow_credentials"] is False
|
||||
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
|
||||
|
||||
|
||||
|
||||
@@ -25,15 +25,15 @@ L.Icon.Default.mergeOptions({
|
||||
});
|
||||
|
||||
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
||||
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
||||
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
||||
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
|
||||
// Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style,
|
||||
// MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map
|
||||
// still renders if that var is missing — CARTO's keyless CDN has proven flaky.
|
||||
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme;
|
||||
// the {z}/{x}/{y} tokens below are substituted by Leaflet at runtime.
|
||||
const MAP_TILE_URL =
|
||||
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const MAP_TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const MAP_TILE_ATTRIBUTION = "© OpenStreetMap contributors";
|
||||
|
||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||
@@ -459,9 +459,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||||
const [clockStr, setClockStr] = useState(() =>
|
||||
new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
@@ -474,15 +471,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Live clock for TOC situational awareness
|
||||
useEffect(() => {
|
||||
const id = setInterval(() =>
|
||||
setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })),
|
||||
1000
|
||||
);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
@@ -623,13 +611,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2 pointer-events-none">
|
||||
<span className="text-ink text-sm font-mono tabular-nums">{clockStr}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2">
|
||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||
@@ -673,15 +656,19 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5">
|
||||
{/* Desktop: left sidebar — offset below the zoom stack + fit-all button
|
||||
so it never overlaps the Leaflet +/- controls (#118). Height is
|
||||
capped and the list scrolls on its own, so the rail never reaches
|
||||
the bottom-right legend. pointer-events are off on the wrapper and
|
||||
back on for the cards, so the map still pans in the gaps. */}
|
||||
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
|
||||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||||
unit counts are pipeline output. Pinned above the scroll area
|
||||
so it cannot be scrolled off the screen it qualifies. */}
|
||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0">
|
||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
|
||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto">
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
||||
{incidents.map((inc) => {
|
||||
const color = severityColor(inc.severity);
|
||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||
|
||||
@@ -75,6 +75,16 @@
|
||||
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
||||
{ "fieldPath": "triggered_at", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts (nav badge) and the /watch \"Triggered Alerts\" tab — where(org_id ==) where(acknowledged == false) orderBy(triggered_at desc). Threw \"the query requires an index\" on every page until this was declared (server-26#51). Field tuple and triggered_at DESCENDING copy the console create_composite link in that issue verbatim, so a gcloud/console create and a firebase deploy converge on one index rather than the ASC-vs-DESC pair that caused server-26#33. Distinct from the (org_id, triggered_at) alert-feed index above (no acknowledged filter) and supersedes the pre-tenancy live alert_events(acknowledged, triggered_at) index #33 says to delete.",
|
||||
"collectionGroup": "alert_events",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
||||
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||
{ "fieldPath": "triggered_at", "order": "DESCENDING" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": []
|
||||
|
||||
Reference in New Issue
Block a user