diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py
index ae9d83b..1ae5ebf 100644
--- a/build/dashboard/mining_dashboard/config/config.py
+++ b/build/dashboard/mining_dashboard/config/config.py
@@ -76,6 +76,16 @@
XVB_TOR_ENABLED = os.environ.get("XVB_TOR_ENABLED", "true").lower() == "true"
XVB_TOR_SOCKS5 = XVB_TOR_PROXY.split("://", 1)[-1]
+# --- Egress posture knobs (#170 Component Health panel) ---
+# Surfaced so the dashboard can show each component's outbound egress route + a privacy roll-up.
+# Defaults are the privacy-safe resting state (firewall fail-closed; p2pool peers over Tor); pithead
+# renders the real values into the dashboard env (docker-compose.yml) so the panel reflects actual
+# config rather than a hardcoded guess. TOR_EGRESS_FIREWALL is the #270 network-layer backstop: when
+# on, any non-Tor egress from the container subnet is DROPPED (fail-closed), so the whole stack is
+# Tor-only regardless of per-app config.
+TOR_EGRESS_FIREWALL = os.environ.get("TOR_EGRESS_FIREWALL", "true").strip().lower() == "true"
+P2POOL_CLEARNET = os.environ.get("P2POOL_CLEARNET", "false").strip().lower() == "true"
+
# New-release check (#224, config.json: dashboard.check_for_updates). Default ON — the dashboard asks
# GitHub for the latest release and shows a header badge linking to it if it's newer than the running
# version. Notify-only (no upgrade — that's #59). The check is routed over the same bridge Tor SOCKS
diff --git a/build/dashboard/mining_dashboard/service/egress.py b/build/dashboard/mining_dashboard/service/egress.py
new file mode 100644
index 0000000..9958116
--- /dev/null
+++ b/build/dashboard/mining_dashboard/service/egress.py
@@ -0,0 +1,272 @@
+"""Egress posture (#170) — for each stack component, its outbound connections and their network
+route (Tor / clearnet / local / inactive), plus a privacy roll-up.
+
+Routes are *derived from the live config*, never hardcoded, so the panel can't drift from reality or
+lie after a regression — the #160 audit's lesson (``--onion-address`` *looked* like Tor but wasn't).
+
+Two backstops matter for whether a clearnet route is actually an IP leak:
+
+* The **#270 egress firewall** (``DOCKER-USER``, fail-closed) DROPs non-Tor egress from the *container*
+ subnet — so a container's clearnet route can't actually leave while it's on.
+* It does **not** cover the **host-networked dashboard** (``network_mode: host``), whose own egress
+ (XvB stats fetch, update check) bypasses ``DOCKER-USER`` entirely. Those rely solely on their
+ SOCKS config — a clearnet route there is a real leak regardless of the firewall.
+
+So a connection is a *leak* only when its route is clearnet AND it isn't neutralised by a backstop.
+"""
+
+from mining_dashboard.config import config
+
+TOR = "tor"
+CLEARNET = "clearnet"
+LOCAL = "local"
+INACTIVE = "inactive"
+
+
+def _xvb_route(xvb_enabled, xvb_tor):
+ if not xvb_enabled:
+ return INACTIVE
+ return TOR if xvb_tor else CLEARNET
+
+
+def compute_egress_posture(
+ *,
+ firewall,
+ p2pool_clearnet,
+ xvb_enabled,
+ xvb_tor,
+ monero_clearnet_sync,
+ tari_clearnet_sync,
+ remote_monero,
+):
+ """Pure derivation of the egress posture from config knobs. Returns ``{components, summary}``."""
+ xvb = _xvb_route(xvb_enabled, xvb_tor)
+
+ # ``firewalled``: is this component's egress on the container subnet the #270 firewall guards?
+ # The dashboard is host-networked, so its own outbound traffic is NOT covered.
+ components = [
+ {
+ "name": "monerod",
+ "firewalled": True,
+ "conns": [
+ {"to": "Monero P2P / tx relay", "route": TOR},
+ *(
+ [{"to": "initial block download (clearnet sync)", "route": CLEARNET}]
+ if monero_clearnet_sync
+ else []
+ ),
+ ],
+ },
+ {
+ "name": "p2pool",
+ "firewalled": True,
+ "conns": [
+ {"to": "sidechain P2P peers", "route": CLEARNET if p2pool_clearnet else TOR},
+ {"to": "monerod RPC/ZMQ", "route": CLEARNET if remote_monero else LOCAL},
+ ],
+ },
+ {
+ "name": "tari",
+ "firewalled": True,
+ "conns": [
+ {"to": "Tari P2P transport", "route": TOR},
+ # dns_seeds=[] (#162); onion peer seeds resolve via Tor — no clearnet DNS.
+ {"to": "DNS resolution", "route": LOCAL},
+ *(
+ [{"to": "initial sync (clearnet)", "route": CLEARNET}]
+ if tari_clearnet_sync
+ else []
+ ),
+ ],
+ },
+ {
+ "name": "xmrig-proxy",
+ "firewalled": True,
+ "conns": [
+ {"to": "upstream pool (local p2pool stratum)", "route": LOCAL},
+ # XvB donation mining dials na.xmrvsbeast.com via the proxy's per-pool socks5 (#166).
+ {"to": "XvB donation pool", "route": xvb},
+ {"to": "dev donation", "route": INACTIVE}, # --donate-level 0 (#166)
+ ],
+ },
+ {
+ "name": "dashboard",
+ "firewalled": False, # host-networked — bypasses the #270 DOCKER-USER firewall
+ "conns": [
+ {"to": "XvB stats (xmrvsbeast.com)", "route": xvb}, # socks5h when on (#163)
+ {"to": "update check (github)", "route": TOR}, # socks5h, #224
+ ],
+ },
+ {
+ "name": "caddy",
+ "firewalled": True,
+ "conns": [{"to": "TLS (internal CA, no ACME)", "route": LOCAL}],
+ },
+ ]
+
+ leaks = 0 # clearnet egress that actually exposes the host IP
+ blocked = 0 # clearnet route a container is configured for, but the firewall DROPs it
+ for comp in components:
+ for conn in comp["conns"]:
+ if conn["route"] != CLEARNET:
+ continue
+ if comp["firewalled"] and firewall:
+ conn["blocked_by_firewall"] = True
+ blocked += 1
+ else:
+ leaks += 1
+
+ if leaks:
+ label = f"{leaks} clearnet egress path(s) exposing your IP"
+ elif blocked:
+ label = f"All egress via Tor ({blocked} clearnet path(s) blocked by the egress firewall)"
+ else:
+ label = "All egress via Tor"
+
+ return {
+ "components": components,
+ "summary": {
+ "firewall": firewall,
+ "leaks": leaks,
+ "blocked_by_firewall": blocked,
+ "all_tor": leaks == 0,
+ "level": "ok" if leaks == 0 else "warn",
+ "label": label,
+ },
+ }
+
+
+def egress_posture_from_config():
+ """Build the posture from the live dashboard config (values pithead rendered into the env)."""
+ return compute_egress_posture(
+ firewall=config.TOR_EGRESS_FIREWALL,
+ p2pool_clearnet=config.P2POOL_CLEARNET,
+ xvb_enabled=config.ENABLE_XVB,
+ xvb_tor=config.XVB_TOR_ENABLED,
+ monero_clearnet_sync=config.MONERO_CLEARNET_SYNC,
+ tari_clearnet_sync=config.TARI_CLEARNET_SYNC,
+ remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST,
+ )
+
+
+# --- Stack topology (#170, trust-boundary view) ----------------------------------------
+# The egress list above answers "is anything leaking?"; the topology answers "how is the whole
+# stack wired?" — every component and the route of each link (ingress, egress, internal). Same
+# config-derived routes, so the two views can never disagree (the summary is shared verbatim).
+
+# Zones, left-to-right by trust: your LAN, the host's container bridge, the Tor hub, the Internet.
+ZONE_LAN = "lan"
+ZONE_HOST = "host"
+ZONE_TOR = "tor"
+ZONE_NET = "internet"
+
+# Nodes bracket the host components with the external actors they actually talk to. ``internal``
+# nodes (the socket proxies) only appear when the operator expands the internal mesh.
+TOPOLOGY_NODES = [
+ {"id": "rigs", "label": "Mining rigs", "zone": ZONE_LAN},
+ {"id": "browser", "label": "Browser", "zone": ZONE_LAN},
+ {"id": "xmrig-proxy", "label": "xmrig-proxy", "zone": ZONE_HOST},
+ {"id": "caddy", "label": "caddy", "zone": ZONE_HOST},
+ {"id": "dashboard", "label": "dashboard", "zone": ZONE_HOST},
+ {"id": "p2pool", "label": "p2pool", "zone": ZONE_HOST},
+ {"id": "monerod", "label": "monerod", "zone": ZONE_HOST},
+ {"id": "tari", "label": "tari", "zone": ZONE_HOST},
+ {"id": "docker", "label": "docker-proxy", "zone": ZONE_HOST, "internal": True},
+ {"id": "tor", "label": "tor", "zone": ZONE_TOR},
+ {"id": "internet", "label": "Tor network", "zone": ZONE_NET},
+]
+
+
+def _edge(src, dst, route, label, kind):
+ return {"from": src, "to": dst, "route": route, "label": label, "kind": kind}
+
+
+def _ext(route):
+ # Where a component's external link lands in the diagram: a Tor-routed link terminates at the
+ # `tor` hub; a clearnet link goes STRAIGHT to the internet node, so a leak visibly bypasses Tor.
+ return "internet" if route == CLEARNET else "tor"
+
+
+def compute_topology(
+ *,
+ firewall,
+ p2pool_clearnet,
+ xvb_enabled,
+ xvb_tor,
+ monero_clearnet_sync,
+ tari_clearnet_sync,
+ remote_monero,
+):
+ """Pure derivation of the stack topology. Returns ``{nodes, edges, summary}``.
+
+ ``kind`` is one of ``ingress`` (inbound from your LAN), ``egress`` (outbound), ``p2p``
+ (bidirectional — egress *and* onion ingress for the P2P daemons), or ``internal`` (host-only
+ plumbing, hidden until expanded). The summary is shared verbatim with the egress list.
+ """
+ posture = compute_egress_posture(
+ firewall=firewall,
+ p2pool_clearnet=p2pool_clearnet,
+ xvb_enabled=xvb_enabled,
+ xvb_tor=xvb_tor,
+ monero_clearnet_sync=monero_clearnet_sync,
+ tari_clearnet_sync=tari_clearnet_sync,
+ remote_monero=remote_monero,
+ )
+ xvb = _xvb_route(xvb_enabled, xvb_tor)
+ sidechain = CLEARNET if p2pool_clearnet else TOR
+ rpc = CLEARNET if remote_monero else LOCAL
+
+ edges = [
+ # Ingress from your LAN — the only listeners actually exposed to the network.
+ _edge("rigs", "xmrig-proxy", LOCAL, "stratum :3333", "ingress"),
+ _edge("browser", "caddy", LOCAL, "https :443", "ingress"),
+ # Daemon P2P: bidirectional (outbound peers + inbound via Tor onion services).
+ _edge("p2pool", _ext(sidechain), sidechain, "sidechain P2P", "p2p"),
+ _edge("monerod", "tor", TOR, "Monero P2P + tx", "p2p"),
+ _edge("tari", "tor", TOR, "Tari P2P", "p2p"),
+ # App-level egress.
+ _edge("xmrig-proxy", _ext(xvb), xvb, "XvB donation", "egress"),
+ _edge("dashboard", "tor", TOR, "update check", "egress"),
+ _edge("dashboard", _ext(xvb), xvb, "XvB stats", "egress"),
+ # The Tor hub to the network: SOCKS egress for every daemon + onion-service ingress.
+ _edge("tor", "internet", TOR, "SOCKS + onion circuits", "p2p"),
+ # Internal mesh (hidden until expanded).
+ _edge("xmrig-proxy", "p2pool", LOCAL, "upstream pool", "internal"),
+ _edge("p2pool", "monerod", rpc, "RPC / ZMQ", "internal"),
+ _edge("p2pool", "tari", LOCAL, "gRPC merge-mine", "internal"),
+ _edge("caddy", "dashboard", LOCAL, "reverse-proxy :8000", "internal"),
+ _edge("dashboard", "monerod", LOCAL, "get_info RPC", "internal"),
+ _edge("dashboard", "xmrig-proxy", LOCAL, "proxy API", "internal"),
+ _edge("dashboard", "tari", LOCAL, "gRPC", "internal"),
+ _edge("dashboard", "docker", LOCAL, "container API", "internal"),
+ ]
+ # Optional clearnet initial-sync paths (#183) bypass the Tor hub straight to the internet.
+ if monero_clearnet_sync:
+ edges.append(_edge("monerod", "internet", CLEARNET, "clearnet IBD", "egress"))
+ if tari_clearnet_sync:
+ edges.append(_edge("tari", "internet", CLEARNET, "clearnet IBD", "egress"))
+
+ # Tag clearnet links as a real leak vs firewall-blocked — same rule as the egress list. Only the
+ # host-networked dashboard escapes the #270 container firewall, so its clearnet links truly leak.
+ for edge in edges:
+ if edge["route"] != CLEARNET:
+ continue
+ if edge["from"] != "dashboard" and firewall:
+ edge["blocked_by_firewall"] = True
+ else:
+ edge["leak"] = True
+
+ return {"nodes": TOPOLOGY_NODES, "edges": edges, "summary": posture["summary"]}
+
+
+def topology_from_config():
+ """Build the topology from the live dashboard config (values pithead rendered into the env)."""
+ return compute_topology(
+ firewall=config.TOR_EGRESS_FIREWALL,
+ p2pool_clearnet=config.P2POOL_CLEARNET,
+ xvb_enabled=config.ENABLE_XVB,
+ xvb_tor=config.XVB_TOR_ENABLED,
+ monero_clearnet_sync=config.MONERO_CLEARNET_SYNC,
+ tari_clearnet_sync=config.TARI_CLEARNET_SYNC,
+ remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST,
+ )
diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index 9e1a0a1..97f6c89 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -6,6 +6,7 @@
import { ChartCard } from "./chart.mjs";
import {
computeEarnings,
+ egressRoute,
formatTimeToShare,
formatXmr,
heroKpis,
@@ -18,6 +19,7 @@ import {
WORKER_COLUMNS,
} from "./logic.mjs";
import { Component, Fragment, html } from "./preact.mjs";
+import { StackTopology } from "./topology.mjs";
// Palette token -> text-colour class (defined in dashboard.css).
const cVar = (v) => "c-" + v;
@@ -459,6 +461,54 @@ function WorkersTable({ workers, summary, ui, onSort }) {
// --- Operational view ----------------------------------------------------------------
+// Component Health & egress posture (#170). The topology map (StackTopology) is the panel: every
+// component and the route of each link, derived from live config (service/egress.py), so it can't
+// drift from reality. The glanceable summary rides in the header badges + the line below; the older
+// per-component egress list lives on as an expandable drawer for the full text detail / a11y.
+function ComponentHealth({ topology, egress }) {
+ if (!topology) return null;
+ const ok = topology.summary.level === "ok";
+ return html`
+
+
Stack Topology & Egress
+
+ ${ok ? "🛡️" : "⚠️"} ${topology.summary.label}
+
+ <${StackTopology} topology=${topology} />
+ ${
+ egress
+ ? html`
+ All connections (per component)
+
+ ${egress.components.map(
+ (comp) => html`
+
+
${comp.name}
+
+ ${comp.conns.map((conn) => {
+ const r = egressRoute(conn.route);
+ return html`
+ -
+ ${r.icon} ${r.label}
+ ${conn.to}${
+ conn.blocked_by_firewall
+ ? html` (firewall-blocked)`
+ : ""
+ }
+
`;
+ })}
+
+
`,
+ )}
+
+ `
+ : ""
+ }
+
`;
+}
+
function DashboardView({
state,
ui,
@@ -499,6 +549,7 @@ function DashboardView({
<${TariCard} tari=${state.tari} />
<${GlobalStats} state=${state} />
<${NetworkCard} state=${state} />
+ <${ComponentHealth} topology=${state.topology} egress=${state.egress} />
`;
}
diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css
index ec4c46f..fedcb81 100644
--- a/build/dashboard/mining_dashboard/web/static/dashboard.css
+++ b/build/dashboard/mining_dashboard/web/static/dashboard.css
@@ -778,3 +778,116 @@ tr:last-child td {
width: 100%;
}
}
+
+/* Component Health & egress-posture panel (#170). */
+.c-bad {
+ color: var(--bad);
+}
+.egress-summary {
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+}
+.egress-component {
+ margin-bottom: 0.5rem;
+}
+.egress-name {
+ font-weight: 600;
+ text-transform: capitalize;
+}
+.egress-conns {
+ list-style: none;
+ margin: 0.15rem 0 0;
+ padding: 0;
+}
+.egress-conn {
+ display: flex;
+ gap: 0.5rem;
+ font-size: 0.85em;
+ padding: 0.1rem 0;
+}
+.egress-route {
+ flex: 0 0 7.5em;
+ font-weight: 600;
+}
+.egress-to {
+ color: var(--text-muted);
+}
+.egress-note {
+ font-style: italic;
+}
+
+/* Stack topology panel (#170). */
+.topo-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ font-size: 0.8em;
+ margin-bottom: 0.4rem;
+}
+.topo-legend {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ color: var(--text-muted);
+}
+.topo-sw {
+ width: 0.8em;
+ height: 0.8em;
+ border-radius: 2px;
+ display: inline-block;
+}
+.topo-toggle {
+ margin-left: auto;
+ background: transparent;
+ color: var(--accent);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.15rem 0.5rem;
+ font-size: inherit;
+ cursor: pointer;
+}
+.topo-toggle:hover {
+ border-color: var(--accent);
+}
+.topo-svg {
+ width: 100%;
+ height: auto;
+}
+.topo-zone {
+ fill: var(--text-muted);
+ font-size: 10px;
+ text-anchor: middle;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.topo-node rect {
+ fill: var(--bg);
+ stroke: var(--border);
+ stroke-width: 1;
+}
+.topo-node text {
+ fill: var(--text);
+ font-size: 11px;
+}
+.topo-hub rect {
+ stroke: var(--ok);
+ stroke-width: 1.5;
+}
+.topo-ext rect {
+ fill: transparent;
+ stroke-dasharray: 3 2;
+}
+.topo-ext text {
+ fill: var(--text-muted);
+}
+/* The per-component egress list, demoted to an expandable drawer under the topology map. */
+.egress-details {
+ margin-top: 0.6rem;
+ font-size: 0.9em;
+}
+.egress-details > summary {
+ cursor: pointer;
+ color: var(--text-muted);
+ margin-bottom: 0.4rem;
+}
diff --git a/build/dashboard/mining_dashboard/web/static/logic.mjs b/build/dashboard/mining_dashboard/web/static/logic.mjs
index c14ce04..8dd644e 100644
--- a/build/dashboard/mining_dashboard/web/static/logic.mjs
+++ b/build/dashboard/mining_dashboard/web/static/logic.mjs
@@ -209,6 +209,36 @@ export function formatTimeToShare(sec) {
return fmtWindowDuration(sec * 1000);
}
+// Egress posture (#170): map a connection's route token to a display glyph + label + CSS token for
+// the Component Health panel. The server (service/egress.py) derives the route from live config; the
+// client only maps it to presentation, no logic of its own (the #61 principle).
+export const EGRESS_ROUTES = {
+ tor: { icon: "🧅", label: "Tor", cls: "ok" },
+ clearnet: { icon: "🌐", label: "Clearnet", cls: "bad" },
+ local: { icon: "🏠", label: "Local/LAN", cls: "muted" },
+ inactive: { icon: "⚪", label: "Inactive", cls: "muted" },
+};
+
+export function egressRoute(route) {
+ return EGRESS_ROUTES[route] || { icon: "❔", label: route || "unknown", cls: "muted" };
+}
+
+// Topology edge routing (#170): the point on a node box's border heading toward a target point, so
+// a connector starts/ends flush on the box edge (not its centre). Pure geometry — `node` is
+// {x, y, w, h}; (tx, ty) the target centre. Used by the StackTopology SVG and unit-tested directly.
+export function boxAnchor(node, tx, ty) {
+ const cx = node.x + node.w / 2;
+ const cy = node.y + node.h / 2;
+ const dx = tx - cx;
+ const dy = ty - cy;
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
+ // Scale the direction vector until it hits the nearest border (half-width / half-height).
+ const sx = dx !== 0 ? node.w / 2 / Math.abs(dx) : Infinity;
+ const sy = dy !== 0 ? node.h / 2 / Math.abs(dy) : Infinity;
+ const s = Math.min(sx, sy);
+ return { x: cx + dx * s, y: cy + dy * s };
+}
+
// Width of a stacked hashrate band's top border-line for one chart segment. Returns 0 when the band
// has zero height across the segment (both endpoints y === 0), so a flat-zero series doesn't paint
// its colored border-line over the other series' edge — the "blue-purple at 100% P2Pool" artifact,
diff --git a/build/dashboard/mining_dashboard/web/static/topology.mjs b/build/dashboard/mining_dashboard/web/static/topology.mjs
new file mode 100644
index 0000000..d9ac722
--- /dev/null
+++ b/build/dashboard/mining_dashboard/web/static/topology.mjs
@@ -0,0 +1,135 @@
+// Stack topology (#170, trust-boundary view). A data-driven SVG of the whole stack: every component
+// is a node, every connection an edge coloured by the route the server derived from live config
+// (service/egress.py · compute_topology). Tor = green; clearnet = red and lands on the `internet`
+// node so a leak visibly BYPASSES the Tor hub; local/LAN = grey. The P2P daemons get a double arrow
+// (egress + onion ingress). The internal host-only mesh is hidden until expanded, so the default
+// view stays focused on what crosses the trust boundary. Geometry is pure (boxAnchor, logic.mjs);
+// this file is presentation only — it carries no routing logic of its own (the #61 principle).
+
+import { boxAnchor } from "./logic.mjs";
+import { Component, html } from "./preact.mjs";
+
+// Fixed layout — the stack is a known, fixed set of components, so positions are hand-placed
+// (left→right by trust: your LAN, the host bridge, the Tor hub, the internet) rather than solved.
+const POS = {
+ rigs: { x: 12, y: 64, w: 88, h: 32 },
+ browser: { x: 12, y: 150, w: 88, h: 32 },
+ "xmrig-proxy": { x: 132, y: 64, w: 104, h: 32 },
+ caddy: { x: 132, y: 150, w: 104, h: 32 },
+ dashboard: { x: 132, y: 226, w: 104, h: 32 },
+ p2pool: { x: 262, y: 64, w: 92, h: 32 },
+ monerod: { x: 262, y: 150, w: 92, h: 32 },
+ tari: { x: 262, y: 226, w: 92, h: 32 },
+ docker: { x: 262, y: 282, w: 92, h: 26 },
+ tor: { x: 392, y: 96, w: 96, h: 40 },
+ internet: { x: 392, y: 220, w: 96, h: 36 },
+};
+
+const ROUTE_COLOR = {
+ tor: "var(--ok)",
+ clearnet: "var(--bad)",
+ local: "var(--text-muted)",
+ inactive: "var(--text-muted)",
+};
+const ROUTE_NAME = { tor: "Tor", clearnet: "Clearnet", local: "Local/LAN", inactive: "Inactive" };
+const ROUTES = ["tor", "clearnet", "local", "inactive"];
+
+const center = (n) => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 });
+const nodeCls = (zone) =>
+ zone === "tor" ? "topo-node topo-hub" : zone === "host" ? "topo-node" : "topo-node topo-ext";
+
+// SVG path for an edge. Most edges are a straight border-to-border segment; the three that would
+// otherwise cross the daemon column are routed orthogonally through a clear lane (xmrig-proxy and
+// dashboard reach the Tor hub over the top / under the bottom; p2pool→tari skips monerod between
+// them). Lanes: y=42 (top), y=312 (bottom), x=372 (right gutter), x=254 (left gutter).
+function edgePath(e, a, b) {
+ if (e.from === "xmrig-proxy" && (e.to === "tor" || e.to === "internet")) {
+ return `M${a.x + a.w / 2},${a.y} V42 H372 V${b.y + b.h / 2} H${b.x}`;
+ }
+ if (e.from === "dashboard" && (e.to === "tor" || e.to === "internet")) {
+ return `M${a.x + a.w / 2},${a.y + a.h} V312 H372 V${b.y + b.h / 2} H${b.x}`;
+ }
+ if (e.from === "p2pool" && e.to === "tari") {
+ return `M${a.x},${a.y + a.h / 2} H254 V${b.y + b.h / 2} H${b.x}`;
+ }
+ const ac = center(a);
+ const bc = center(b);
+ const s = boxAnchor(a, bc.x, bc.y);
+ const t = boxAnchor(b, ac.x, ac.y);
+ return `M${s.x},${s.y} L${t.x},${t.y}`;
+}
+
+export class StackTopology extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { internal: false };
+ }
+
+ render({ topology }, { internal }) {
+ if (!topology) return null;
+ const nodes = topology.nodes.filter((n) => internal || !n.internal);
+ const edges = topology.edges.filter((e) => internal || e.kind !== "internal");
+ const byId = {};
+ for (const n of topology.nodes) byId[n.id] = n;
+
+ return html`
+
+
+ Tor
+ Clearnet
+ Local / LAN
+
+
+
+
`;
+ }
+
+ _edge(e, byId) {
+ const a = POS[e.from];
+ const b = POS[e.to];
+ if (!a || !b) return null;
+ const key = e.leak ? "clearnet" : e.route;
+ const color = ROUTE_COLOR[key] || ROUTE_COLOR.local;
+ const dash = e.kind === "internal" ? "3 3" : e.blocked_by_firewall ? "2 3" : null;
+ const note = e.leak ? " — LEAK" : e.blocked_by_firewall ? " — firewall-blocked" : "";
+ const from = byId[e.from]?.label || e.from;
+ const to = byId[e.to]?.label || e.to;
+ const tip = `${from} → ${to}: ${e.label} · ${ROUTE_NAME[key] || key}${note}`;
+ return html`
+
+ ${tip}
+ `;
+ }
+}
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index 4463292..171a489 100644
--- a/build/dashboard/mining_dashboard/web/views.py
+++ b/build/dashboard/mining_dashboard/web/views.py
@@ -33,6 +33,7 @@
is_ip_address,
)
from mining_dashboard.service.earnings import xmr_per_hs_day
+from mining_dashboard.service.egress import egress_posture_from_config, topology_from_config
from mining_dashboard.service.metrics import build_metrics
from mining_dashboard.version import resolve_version
@@ -812,6 +813,16 @@ def host_display_addr(host):
return addr
+def _egress_badge(summary):
+ """Glanceable header badge for the egress posture (#170): green when Tor-only, red on a leak."""
+ ok = summary["level"] == "ok"
+ return {
+ "variant": "ok" if ok else "bad",
+ "text": "🛡️ Tor-only egress" if ok else f"⚠️ {summary['leaks']} clearnet egress",
+ "title": summary["label"],
+ }
+
+
def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASHRATE_WINDOW):
"""Assemble the full ``/api/state`` payload — the contract the client renders against.
@@ -828,6 +839,13 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
mode_tok, p2p_tok, xvb_tok = _mode_palette(metrics.mode)
pool_net = build_pool_network(data, metrics)
+ egress = egress_posture_from_config() # per-component egress route + privacy roll-up (#170)
+ topology = (
+ topology_from_config()
+ ) # full stack wiring for the topology panel (#170); shares summary
+ badges = build_badges(data, metrics, mode_tok, db_healthy)
+ badges.append(_egress_badge(egress["summary"])) # glanceable Tor-only / leak header badge
+
return {
"syncing": metrics.global_syncing,
"page_title": "Mining Dashboard - Syncing"
@@ -842,7 +860,7 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
"window": {"from": window[0], "to": window[1]} if window else None,
"avg_window": avg_window,
"avg_windows": HASHRATE_WINDOWS,
- "badges": build_badges(data, metrics, mode_tok, db_healthy),
+ "badges": badges,
"db_healthy": db_healthy,
"hashrate": build_hashrate(metrics, mode_tok, p2p_tok, xvb_tok),
"system": build_system(data),
@@ -858,6 +876,8 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
"tari": build_tari(data),
"workers": build_workers(data.get("workers", [])),
"proxy_summary": build_proxy_summary(data),
+ "egress": egress,
+ "topology": topology,
"chart": build_chart(history, data.get("shares", []), range_arg, window, avg_window),
}
diff --git a/build/dashboard/tests/frontend/logic.test.mjs b/build/dashboard/tests/frontend/logic.test.mjs
index 7ee6cd7..99e6694 100644
--- a/build/dashboard/tests/frontend/logic.test.mjs
+++ b/build/dashboard/tests/frontend/logic.test.mjs
@@ -19,6 +19,7 @@ import {
parseHashrate, computeEarnings, formatXmr, formatTimeToShare,
DAYS_PER_MONTH, DAYS_PER_YEAR,
bandBorderWidth, uptimeCell,
+ egressRoute, boxAnchor,
} from '../../mining_dashboard/web/static/logic.mjs';
const col = (key) => WORKER_COLUMNS.findIndex((c) => c.key === key);
@@ -297,3 +298,26 @@ test('uptimeCell: online shows uptime, offline shows DOWN', () => {
assert.equal(uptimeCell({ status: 'online', uptime_str: '3h 20m' }), '3h 20m');
assert.equal(uptimeCell({ status: 'offline', uptime_str: '99h 9m' }), 'DOWN');
});
+
+// #170: egressRoute maps a server route token to a display glyph + label + CSS colour token.
+test('egressRoute: known routes map to icon/label/class; unknown falls back to muted', () => {
+ assert.deepEqual(egressRoute('tor'), { icon: '🧅', label: 'Tor', cls: 'ok' });
+ assert.equal(egressRoute('clearnet').cls, 'bad');
+ assert.equal(egressRoute('local').cls, 'muted');
+ assert.equal(egressRoute('inactive').cls, 'muted');
+ const unknown = egressRoute('weird');
+ assert.equal(unknown.cls, 'muted');
+ assert.equal(unknown.label, 'weird');
+});
+
+// #170: boxAnchor returns the point on a node box's border heading toward a target — for topology
+// connectors that start/end flush on the box edge.
+test('boxAnchor: lands on the border facing the target, not the centre', () => {
+ const node = { x: 100, y: 100, w: 40, h: 20 }; // centre (120, 110)
+ // Target straight to the right → exits the right edge (x = 140) at the centre height.
+ assert.deepEqual(boxAnchor(node, 300, 110), { x: 140, y: 110 });
+ // Target straight up → exits the top edge (y = 100) at the centre width.
+ assert.deepEqual(boxAnchor(node, 120, 0), { x: 120, y: 100 });
+ // Degenerate (target == centre) → the centre itself, no NaN.
+ assert.deepEqual(boxAnchor(node, 120, 110), { x: 120, y: 110 });
+});
diff --git a/build/dashboard/tests/service/test_egress.py b/build/dashboard/tests/service/test_egress.py
new file mode 100644
index 0000000..ea3c621
--- /dev/null
+++ b/build/dashboard/tests/service/test_egress.py
@@ -0,0 +1,183 @@
+"""Tests for the #170 egress-posture derivation."""
+
+from mining_dashboard.service.egress import (
+ CLEARNET,
+ INACTIVE,
+ TOR,
+ compute_egress_posture,
+ compute_topology,
+)
+
+# The privacy-safe resting config: firewall on, p2pool over Tor, XvB over Tor, local node, no sync.
+SAFE = {
+ "firewall": True,
+ "p2pool_clearnet": False,
+ "xvb_enabled": True,
+ "xvb_tor": True,
+ "monero_clearnet_sync": False,
+ "tari_clearnet_sync": False,
+ "remote_monero": False,
+}
+
+
+def _posture(**overrides):
+ return compute_egress_posture(**{**SAFE, **overrides})
+
+
+def _conn(posture, component, needle):
+ comp = next(c for c in posture["components"] if c["name"] == component)
+ return next(c for c in comp["conns"] if needle in c["to"])
+
+
+def test_safe_config_is_all_tor():
+ p = _posture()
+ assert p["summary"] == {
+ "firewall": True,
+ "leaks": 0,
+ "blocked_by_firewall": 0,
+ "all_tor": True,
+ "level": "ok",
+ "label": "All egress via Tor",
+ }
+
+
+def test_p2pool_clearnet_blocked_by_firewall_is_not_a_leak():
+ p = _posture(p2pool_clearnet=True, firewall=True)
+ assert _conn(p, "p2pool", "sidechain")["route"] == CLEARNET
+ assert _conn(p, "p2pool", "sidechain")["blocked_by_firewall"] is True
+ assert p["summary"]["leaks"] == 0
+ assert p["summary"]["blocked_by_firewall"] == 1
+ assert p["summary"]["all_tor"] is True # fail-closed: configured-clearnet can't actually leave
+
+
+def test_p2pool_clearnet_without_firewall_is_a_leak():
+ p = _posture(p2pool_clearnet=True, firewall=False)
+ assert p["summary"]["leaks"] == 1
+ assert p["summary"]["level"] == "warn"
+ assert "exposing your IP" in p["summary"]["label"]
+
+
+def test_host_networked_dashboard_leaks_despite_firewall():
+ # The dashboard's XvB stats fetch is host-networked, so the #270 container firewall can't cover
+ # it — disabling XvB-over-Tor leaks the host IP even with the firewall on. This is the key nuance.
+ p = _posture(xvb_tor=False, firewall=True)
+ assert _conn(p, "dashboard", "XvB stats")["route"] == CLEARNET
+ assert _conn(p, "dashboard", "XvB stats").get("blocked_by_firewall") is None
+ # The xmrig-proxy donation dial (a container) IS blocked by the firewall, but the dashboard isn't.
+ assert _conn(p, "xmrig-proxy", "XvB donation")["blocked_by_firewall"] is True
+ assert p["summary"]["leaks"] >= 1
+ assert p["summary"]["all_tor"] is False
+
+
+def test_xvb_disabled_routes_are_inactive():
+ p = _posture(xvb_enabled=False)
+ assert _conn(p, "dashboard", "XvB stats")["route"] == INACTIVE
+ assert _conn(p, "xmrig-proxy", "XvB donation")["route"] == INACTIVE
+ assert p["summary"]["leaks"] == 0
+
+
+def test_remote_monerod_rpc_is_clearnet():
+ assert _conn(_posture(remote_monero=False), "p2pool", "monerod RPC")["route"] != CLEARNET
+ assert _conn(_posture(remote_monero=True), "p2pool", "monerod RPC")["route"] == CLEARNET
+
+
+def test_clearnet_initial_sync_surfaces_only_when_enabled():
+ base = _posture()
+ assert not any(
+ "initial" in c["to"]
+ for c in next(x for x in base["components"] if x["name"] == "monerod")["conns"]
+ )
+ synced = _posture(monero_clearnet_sync=True, firewall=False)
+ assert _conn(synced, "monerod", "initial block download")["route"] == CLEARNET
+
+
+def test_monerod_p2p_always_tor():
+ assert _conn(_posture(firewall=False), "monerod", "Monero P2P")["route"] == TOR
+
+
+# --- Topology (#170 trust-boundary view) -----------------------------------------------
+
+
+def _topo(**overrides):
+ return compute_topology(**{**SAFE, **overrides})
+
+
+def _edge(topo, src, dst):
+ return next(e for e in topo["edges"] if e["from"] == src and e["to"] == dst)
+
+
+def _from(topo, src):
+ return [e for e in topo["edges"] if e["from"] == src]
+
+
+def test_topology_summary_is_shared_with_egress_list():
+ # The badge can never disagree with the map: same knobs in, identical summary out.
+ for overrides in ({}, {"xvb_tor": False}, {"p2pool_clearnet": True, "firewall": False}):
+ assert _topo(**overrides)["summary"] == _posture(**overrides)["summary"]
+
+
+def test_topology_safe_has_no_leaks_and_hub_nodes():
+ topo = _topo()
+ ids = {n["id"] for n in topo["nodes"]}
+ assert {"tor", "internet", "rigs", "browser"} <= ids
+ assert not any(e.get("leak") for e in topo["edges"])
+ assert topo["summary"]["all_tor"] is True
+
+
+def test_topology_lan_ingress_edges():
+ topo = _topo()
+ rigs = _edge(topo, "rigs", "xmrig-proxy")
+ assert rigs["kind"] == "ingress" and rigs["route"] == "local"
+ assert _edge(topo, "browser", "caddy")["kind"] == "ingress"
+
+
+def test_topology_daemon_p2p_is_bidirectional_over_tor():
+ topo = _topo()
+ for daemon in ("monerod", "tari", "p2pool"):
+ edge = _edge(topo, daemon, "tor")
+ assert edge["kind"] == "p2p", daemon # egress + onion ingress
+ assert edge["route"] == TOR, daemon
+
+
+def test_topology_clearnet_link_bypasses_the_tor_hub():
+ # A clearnet route must land on `internet`, not `tor`, so a leak visibly skips the hub.
+ topo = _topo(p2pool_clearnet=True, firewall=False)
+ edge = _edge(topo, "p2pool", "internet")
+ assert edge["route"] == CLEARNET and edge["leak"] is True
+ assert not any(e["to"] == "tor" and e["from"] == "p2pool" for e in topo["edges"])
+
+
+def test_topology_clearnet_blocked_by_firewall_is_not_a_leak():
+ topo = _topo(p2pool_clearnet=True, firewall=True)
+ edge = _edge(topo, "p2pool", "internet")
+ assert edge.get("blocked_by_firewall") is True and edge.get("leak") is None
+ assert topo["summary"]["all_tor"] is True
+
+
+def test_topology_host_networked_dashboard_xvb_leaks_but_proxy_is_blocked():
+ topo = _topo(xvb_tor=False, firewall=True)
+ # The dashboard's XvB stats fetch is host-networked → the #270 firewall can't cover it.
+ assert _edge(topo, "dashboard", "internet")["leak"] is True
+ # The xmrig-proxy XvB dial IS a container → the firewall blocks its clearnet route.
+ assert _edge(topo, "xmrig-proxy", "internet").get("blocked_by_firewall") is True
+
+
+def test_topology_xvb_disabled_is_inactive_not_a_leak():
+ topo = _topo(xvb_enabled=False)
+ assert _edge(topo, "xmrig-proxy", "tor")["route"] == INACTIVE
+ assert _edge(topo, "dashboard", "tor") # update check still present
+ assert not any(e.get("leak") for e in topo["edges"])
+
+
+def test_topology_internal_mesh_is_flagged_and_includes_merge_mining():
+ topo = _topo()
+ merge = _edge(topo, "p2pool", "tari")
+ assert merge["kind"] == "internal" and "merge-mine" in merge["label"]
+ docker = next(n for n in topo["nodes"] if n["id"] == "docker")
+ assert docker.get("internal") is True
+
+
+def test_topology_clearnet_sync_adds_bypass_edge():
+ topo = _topo(monero_clearnet_sync=True, firewall=False)
+ edge = _edge(topo, "monerod", "internet")
+ assert edge["route"] == CLEARNET and edge["leak"] is True
diff --git a/docker-compose.yml b/docker-compose.yml
index 34ff622..6c8a259 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -362,6 +362,10 @@ services:
- MONERO_CLEARNET_SYNC=${MONERO_CLEARNET_SYNC:-false}
- TARI_CLEARNET_SYNC=${TARI_CLEARNET_SYNC:-false}
- CLEARNET_STATE_DIR=/clearnet-state
+ # Egress-posture panel (#170): the #270 firewall state + p2pool's clearnet toggle, so the
+ # dashboard can show each component's real egress route rather than a guess.
+ - TOR_EGRESS_FIREWALL=${TOR_EGRESS_FIREWALL:-true}
+ - P2POOL_CLEARNET=${P2POOL_CLEARNET:-false}
- P2POOL_URL=${P2POOL_URL}
- MONERO_WALLET_ADDRESS=${MONERO_WALLET_ADDRESS}
- XVB_ENABLED=${XVB_ENABLED}
diff --git a/docs/test-inventory.md b/docs/test-inventory.md
index d285113..339c942 100644
--- a/docs/test-inventory.md
+++ b/docs/test-inventory.md
@@ -4,7 +4,7 @@ _Generated by `make test-inventory` ([`tests/inventory.sh`](../tests/inventory.s
edit by hand** — re-run the target to refresh. See [Testing Strategy](testing-strategy.md) for
how the tiers fit together._
-**Totals:** 511 dashboard unit tests · 12 contract tests · 31 frontend
+**Totals:** 529 dashboard unit tests · 12 contract tests · 33 frontend
tests · 46 `pithead` shell sections · 17 harness self-test sections ·
9 live config scenarios (17 axis values) · 6 mini-stack scenarios.
@@ -14,8 +14,8 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
| Tier | Suite | Cases |
|---|---|---|
-| 1 — Unit | dashboard pytest | 511 |
-| 1 — Unit | frontend (node --test) | 31 |
+| 1 — Unit | dashboard pytest | 529 |
+| 1 — Unit | frontend (node --test) | 33 |
| 1 — Unit | `pithead` shell suite | 46 sections |
| 1 — Unit | compose interpolation + hardening (#90) | 1 |
| 2 — Contract | fake-daemon clients | 12 |
@@ -27,7 +27,7 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
## Tier 1 — Unit & component
-### Dashboard (pytest) — 511 tests
+### Dashboard (pytest) — 529 tests
#### tests/client/test_docker_control.py — 6
- test_tcp_scheme_rewritten_to_http
@@ -302,6 +302,26 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
- test_linear_in_inputs
- test_missing_or_bad_inputs_are_zero
+#### tests/service/test_egress.py — 18
+- test_safe_config_is_all_tor
+- test_p2pool_clearnet_blocked_by_firewall_is_not_a_leak
+- test_p2pool_clearnet_without_firewall_is_a_leak
+- test_host_networked_dashboard_leaks_despite_firewall
+- test_xvb_disabled_routes_are_inactive
+- test_remote_monerod_rpc_is_clearnet
+- test_clearnet_initial_sync_surfaces_only_when_enabled
+- test_monerod_p2p_always_tor
+- test_topology_summary_is_shared_with_egress_list
+- test_topology_safe_has_no_leaks_and_hub_nodes
+- test_topology_lan_ingress_edges
+- test_topology_daemon_p2p_is_bidirectional_over_tor
+- test_topology_clearnet_link_bypasses_the_tor_hub
+- test_topology_clearnet_blocked_by_firewall_is_not_a_leak
+- test_topology_host_networked_dashboard_xvb_leaks_but_proxy_is_blocked
+- test_topology_xvb_disabled_is_inactive_not_a_leak
+- test_topology_internal_mesh_is_flagged_and_includes_merge_mining
+- test_topology_clearnet_sync_adds_bypass_edge
+
#### tests/service/test_metrics.py — 40
- test_empty_history_returns_zero
- test_averages_v_p2pool_in_window
@@ -592,7 +612,7 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
- test_no_when_in_tier_but_no_share
- test_na_when_xvb_off
-### Frontend logic (node --test) — 31 tests
+### Frontend logic (node --test) — 33 tests
- sortWorkers: null index keeps the server-provided order
- sortWorkers: numeric columns sort numerically, not lexically
- sortWorkers: hashrate column also sorts numerically
@@ -624,6 +644,8 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
- heroKpis: total is accent-coloured; blocks and tier carry no colour class
- bandBorderWidth: zero-height segments get no border, real ones keep full width
- uptimeCell: online shows uptime, offline shows DOWN
+- egressRoute: known routes map to icon/label/class; unknown falls back to muted
+- boxAnchor: lands on the border facing the target, not the centre
### `pithead` shell suite (tests/stack/run.sh) — 46 sections
- unit: resolve_default
@@ -822,5 +844,5 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
---
-_Grand total: **632** enumerated cases/sections across the four tiers (plus the live
+_Grand total: **652** enumerated cases/sections across the four tiers (plus the live
lifecycle and fault-injection phases, which are exercised on a real server)._
diff --git a/pithead b/pithead
index b97003d..b4e707a 100755
--- a/pithead
+++ b/pithead
@@ -1619,6 +1619,8 @@ parse_and_validate_config() {
is_ipv4 "${NETWORK_PREFIX}.0" || error "network.subnet is not a valid IPv4 /24 (got \"$NETWORK_SUBNET\")."
# Fail-closed Tor-only egress firewall (#270); default on. Renders to .env so `up` can read it.
TOR_EGRESS_FIREWALL=$(normalize_bool "$(jq -r '.network.tor_egress_firewall // true' "$CONFIG_FILE")")
+ # Surfaced for the dashboard's egress-posture panel (#170); mirrors what p2pool_outbound_flags reads.
+ P2POOL_CLEARNET=$(normalize_bool "$(jq -r '.p2pool.clearnet // false' "$CONFIG_FILE")")
if [ "$MONERO_MODE" == "remote" ]; then
local remote_host
remote_host=$(jq -r '.monero.remote.host // empty' "$CONFIG_FILE")
@@ -2073,6 +2075,7 @@ P2POOL_URL=${NETWORK_PREFIX}.28:3333
NETWORK_SUBNET=$NETWORK_SUBNET
NETWORK_PREFIX=$NETWORK_PREFIX
TOR_EGRESS_FIREWALL=$TOR_EGRESS_FIREWALL
+P2POOL_CLEARNET=$P2POOL_CLEARNET
PROXY_API_PORT=3344
PROXY_AUTH_TOKEN=$PROXY_AUTH_TOKEN
PROXY_DONATE_LEVEL=$DONATE_LEVEL