Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
35 changes: 35 additions & 0 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
31 changes: 31 additions & 0 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
NetworkProfile,
PostureBadge,
SegmentationInsight,
WanInfo,
} from "../types/api";

// ── Accent stripe colours per stat card ───────────────────────────────────────
Expand Down Expand Up @@ -201,6 +202,21 @@ function useSegmentation() {
return data;
}

function useWanInfo() {
const [info, setInfo] = useState<WanInfo | null>(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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -376,6 +393,20 @@ export function DashboardPage() {
action={triggerButton}
/>

{wanInfo?.wan_ip && (
<p className="mb-4 -mt-3 text-xs text-[var(--color-text-secondary)]">
<span
title="This is what the internet sees as your network address"
className="inline-flex items-center gap-1 cursor-default"
>
🌐 WAN IP:{" "}
<span className="font-mono text-[var(--color-text-primary)]">
{wanInfo.wan_ip}
</span>
</span>
</p>
)}

{noScansYet && (
<Card className="mb-6 flex flex-col items-center gap-4 py-10 text-center">
<div className="text-4xl">🛡️</div>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 28 additions & 0 deletions frontend/tests/pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function buildFetch(overrides: Record<string, unknown> = {}) {
recommendations: [],
},
"/api/topology": [],
"/api/network/wan": { wan_ip: null, detected_at: null },
...overrides,
};
// match base path for parameterised URLs
Expand Down Expand Up @@ -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(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByText("Dashboard")).toBeInTheDocument(),
);
expect(screen.queryByText(/WAN IP/i)).not.toBeInTheDocument();
});
});

// ── DevicesPage ───────────────────────────────────────────────────────────────
Expand Down
Loading