From 19bbdd046d465e4279371eb2b7940d402e5003ab Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 2 Mar 2026 13:05:18 +0100 Subject: [PATCH] feat: WAN exposure awareness (#96) - _fetch_wan_ip() in scan_runner calls checkip.amazonaws.com (3s timeout) - _update_wan_ip() stores wan_ip + wan_ip_detected_at as AppSettings each scan - GET /api/network/wan returns { wan_ip, detected_at } from AppSettings - Dashboard: useWanInfo hook fetches /api/network/wan on mount - Dashboard: WAN IP chip rendered below page header (hidden when null) - Tooltip: 'This is what the internet sees as your network address' - 2 new backend tests (53 total), 2 new frontend tests (146 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/api/__init__.py | 21 +++++++++++++++++ backend/app/scan_runner.py | 35 ++++++++++++++++++++++++++++ backend/tests/test_api.py | 33 ++++++++++++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 31 ++++++++++++++++++++++++ frontend/src/types/api.ts | 5 ++++ frontend/tests/pages.test.tsx | 28 ++++++++++++++++++++++ 6 files changed, 153 insertions(+) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 1f97262..3a3a058 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -960,3 +960,24 @@ def _highest_severity(d) -> str | None: # noqa: ANN001 — SQLAlchemy instance ) for d in devices ] + + +# ── /api/network/wan ────────────────────────────────────────────────────────── + + +class WanInfoOut(BaseModel): + wan_ip: str | None + detected_at: str | None # ISO-8601 timestamp or None + + +@router.get("/network/wan", response_model=WanInfoOut) +def get_wan_info(db: Annotated[Session, Depends(get_db)]) -> WanInfoOut: + """Return the last detected public WAN IP address and when it was recorded.""" + from app.models.settings import AppSetting + + wan_ip_row = db.get(AppSetting, "wan_ip") + detected_at_row = db.get(AppSetting, "wan_ip_detected_at") + return WanInfoOut( + wan_ip=wan_ip_row.value if wan_ip_row else None, + detected_at=detected_at_row.value if detected_at_row else None, + ) diff --git a/backend/app/scan_runner.py b/backend/app/scan_runner.py index 5c92473..01d8e63 100644 --- a/backend/app/scan_runner.py +++ b/backend/app/scan_runner.py @@ -21,6 +21,38 @@ logger = logging.getLogger(__name__) +def _fetch_wan_ip() -> str | None: + """Fetch public WAN IP from checkip.amazonaws.com with a 3-second timeout. + + Returns the IP string on success, or None if the network is unreachable / request fails. + """ + import urllib.request + + try: + with urllib.request.urlopen("https://checkip.amazonaws.com", timeout=3) as resp: + return resp.read().decode().strip() + except Exception: # noqa: BLE001 -- best-effort; failures silently skipped + return None + + +def _update_wan_ip(db: Session) -> None: + """Detect public WAN IP and store it as AppSettings. Silently ignores failures.""" + wan_ip = _fetch_wan_ip() + if wan_ip is None: + return + from app.models.settings import AppSetting + + now = datetime.now(tz=UTC).isoformat() + for key, value in (("wan_ip", wan_ip), ("wan_ip_detected_at", now)): + row = db.get(AppSetting, key) + if row is None: + db.add(AppSetting(key=key, value=value)) + else: + row.value = value + db.commit() + logger.info("WAN IP updated: %s", wan_ip) + + @dataclass class _PersistSummary: """Summary of what changed during a single call to _persist_result.""" @@ -106,6 +138,9 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: generate_all_recommendations(db) + # Best-effort WAN IP detection (3 s timeout, never fails the scan) + _update_wan_ip(db) + # Fire webhook notification (new devices or critical risks) from app.notifications import ( notify_scan_complete, # noqa: PLC0415 — deferred to avoid circular import at module level diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 66d84c0..5c81023 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -932,3 +932,36 @@ def test_topology_gateway_detection_by_type(client, db_engine): db.delete(router) db.commit() db.close() + + +# ── /api/network/wan ────────────────────────────────────────────────────────── + + +def test_wan_no_data(client): + """GET /api/network/wan returns nulls when no WAN IP has been recorded.""" + resp = client.get("/api/network/wan") + assert resp.status_code == 200 + body = resp.json() + assert body["wan_ip"] is None + assert body["detected_at"] is None + + +def test_wan_returns_stored_ip(client, db_engine): + """GET /api/network/wan returns the IP and timestamp stored in AppSettings.""" + from app.models.settings import AppSetting + + S = sessionmaker(bind=db_engine) # noqa: N806 -- uppercase matches SQLAlchemy Session convention + db = S() + db.add(AppSetting(key="wan_ip", value="203.0.113.42")) + db.add(AppSetting(key="wan_ip_detected_at", value="2026-03-02T12:00:00+00:00")) + db.commit() + + resp = client.get("/api/network/wan") + assert resp.status_code == 200 + body = resp.json() + assert body["wan_ip"] == "203.0.113.42" + assert body["detected_at"] == "2026-03-02T12:00:00+00:00" + + db.query(AppSetting).filter(AppSetting.key.in_(["wan_ip", "wan_ip_detected_at"])).delete() + db.commit() + db.close() diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b1f817a..dcb5af1 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -20,6 +20,7 @@ import type { NetworkProfile, PostureBadge, SegmentationInsight, + WanInfo, } from "../types/api"; // ── Accent stripe colours per stat card ─────────────────────────────────────── @@ -201,6 +202,21 @@ function useSegmentation() { return data; } +function useWanInfo() { + const [info, setInfo] = useState(null); + + useEffect(() => { + fetch("/api/network/wan") + .then((r) => r.json()) + .then((d: WanInfo) => { + if (d && d.wan_ip !== undefined) setInfo(d); + }) + .catch(() => {}); + }, []); + + return info; +} + function SegmentationAdvisory({ data, onDismiss, @@ -317,6 +333,7 @@ export function DashboardPage() { const { posture, yesCount, total } = usePostureBadge(); const activeProfile = useActiveProfile(); const segmentation = useSegmentation(); + const wanInfo = useWanInfo(); const [segmentationDismissed, setSegmentationDismissed] = useState(false); const lastScan = scans[0] ?? null; @@ -376,6 +393,20 @@ export function DashboardPage() { action={triggerButton} /> + {wanInfo?.wan_ip && ( +

+ + 🌐 WAN IP:{" "} + + {wanInfo.wan_ip} + + +

+ )} + {noScansYet && (
🛡️
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index efd5cc6..7700ce1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -177,3 +177,8 @@ export interface TopologyNode { security_score: number; is_gateway: boolean; } + +export interface WanInfo { + wan_ip: string | null; + detected_at: string | null; // ISO-8601 or null +} diff --git a/frontend/tests/pages.test.tsx b/frontend/tests/pages.test.tsx index faa9cb9..3fe7dac 100644 --- a/frontend/tests/pages.test.tsx +++ b/frontend/tests/pages.test.tsx @@ -113,6 +113,7 @@ function buildFetch(overrides: Record = {}) { recommendations: [], }, "/api/topology": [], + "/api/network/wan": { wan_ip: null, detected_at: null }, ...overrides, }; // match base path for parameterised URLs @@ -198,6 +199,33 @@ describe("DashboardPage", () => { expect(screen.getByText(/Scan #99 started/i)).toBeInTheDocument(), ); }); + it("shows WAN IP when available", async () => { + vi.stubGlobal( + "fetch", + buildFetch({ "/api/network/wan": { wan_ip: "1.2.3.4", detected_at: "2026-03-02T12:00:00Z" } }), + ); + render( + + + , + ); + await waitFor(() => + expect(screen.getByText("1.2.3.4")).toBeInTheDocument(), + ); + }); + + it("hides WAN IP row when wan_ip is null", async () => { + vi.stubGlobal("fetch", buildFetch()); + render( + + + , + ); + await waitFor(() => + expect(screen.getByText("Dashboard")).toBeInTheDocument(), + ); + expect(screen.queryByText(/WAN IP/i)).not.toBeInTheDocument(); + }); }); // ── DevicesPage ───────────────────────────────────────────────────────────────