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 + +
+ + + ${ROUTES.map( + (r) => html` + + + + + + `, + )} + + LAN + host — mining_net bridge + Tor → internet + ${edges.map((e) => this._edge(e, byId)).filter(Boolean)} + ${nodes.map((n) => { + const p = POS[n.id]; + if (!p) return null; + return html` + + + ${n.label} + `; + })} + +
`; + } + + _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