From a1945c2eeb0eabe1311cfbb4015de13788050914 Mon Sep 17 00:00:00 2001 From: keegreil Date: Sun, 13 Sep 2026 08:37:34 -0400 Subject: [PATCH] fix: streamline private appliance onboarding --- boot.tests/SecurityHardeningTests.cs | 13 ++++ .../Controllers/BootNetworkController.cs | 4 +- .../Controllers/DashboardController.cs | 30 +++++++-- boot_portal/Models/DashboardModels.cs | 1 + boot_portal/Models/PoolConfig.cs | 3 + boot_portal/Pages/Setup.cshtml | 14 +++++ boot_portal/Pages/Setup.cshtml.cs | 24 +++++++ .../Services/DashboardReadModelService.cs | 1 + boot_portal/ui/AGENTS.md | 7 ++- boot_portal/ui/src/App.tsx | 22 +++++-- boot_portal/ui/src/api.ts | 2 +- boot_portal/ui/src/hooks/useDashboard.ts | 10 ++- boot_portal/ui/src/modules/index.tsx | 2 +- boot_portal/ui/src/styles.css | 63 ++++++++++++++++++- boot_portal/ui/src/test/fixture.ts | 1 + boot_portal/ui/src/types.ts | 1 + boot_portal/wwwroot/setup.css | 23 +++++++ boot_portal/wwwroot/setup.js | 10 +-- 18 files changed, 206 insertions(+), 25 deletions(-) diff --git a/boot.tests/SecurityHardeningTests.cs b/boot.tests/SecurityHardeningTests.cs index 6ba03aa..167671e 100644 --- a/boot.tests/SecurityHardeningTests.cs +++ b/boot.tests/SecurityHardeningTests.cs @@ -35,6 +35,19 @@ public void AdminApiRequiresStrongKeyInSovereignMode() error.Contains("admin_api_key", StringComparison.OrdinalIgnoreCase))); } + [TestMethod] + public void PrivateDashboardDiagnosticsRequireExplicitApplianceOptInOrAdminAuth() + { + var config = ValidConfig(); + + Assert.IsFalse(config.TrustedPrivateDashboardEnabled); + Assert.IsFalse(DashboardController.CanViewOperatorDiagnostics(config, adminAuthorized: false)); + Assert.IsTrue(DashboardController.CanViewOperatorDiagnostics(config, adminAuthorized: true)); + + config.TrustedPrivateDashboardEnabled = true; + Assert.IsTrue(DashboardController.CanViewOperatorDiagnostics(config, adminAuthorized: false)); + } + [TestMethod] public void StoredMinerLabelIsBoundedAndMarkupFree() { diff --git a/boot_portal/Controllers/BootNetworkController.cs b/boot_portal/Controllers/BootNetworkController.cs index 2751903..2c76479 100644 --- a/boot_portal/Controllers/BootNetworkController.cs +++ b/boot_portal/Controllers/BootNetworkController.cs @@ -554,7 +554,9 @@ public IActionResult TombstonePeer([FromBody] BootPeerTombstoneRequest request) } private bool CanViewOperatorDiagnostics() => - _poolConfig.PublicOperatorDiagnosticsEnabled || IsAdminAuthorized(); + _poolConfig.PublicOperatorDiagnosticsEnabled || + _poolConfig.TrustedPrivateDashboardEnabled || + IsAdminAuthorized(); private bool IsAdminAuthorized() { diff --git a/boot_portal/Controllers/DashboardController.cs b/boot_portal/Controllers/DashboardController.cs index 85f489b..e9a5402 100644 --- a/boot_portal/Controllers/DashboardController.cs +++ b/boot_portal/Controllers/DashboardController.cs @@ -12,15 +12,18 @@ public sealed class DashboardController : ControllerBase private readonly BootProtocolStateService _stateService; private readonly DashboardReadModelService _dashboard; private readonly DashboardVisualizationJournalService _visualization; + private readonly PoolConfig _poolConfig; public DashboardController( BootProtocolStateService stateService, DashboardReadModelService dashboard, - DashboardVisualizationJournalService visualization) + DashboardVisualizationJournalService visualization, + PoolConfig poolConfig) { _stateService = stateService; _dashboard = dashboard; _visualization = visualization; + _poolConfig = poolConfig; } [EnableRateLimiting("dashboard-read")] @@ -56,7 +59,9 @@ public IActionResult GetAddress(string address) public IActionResult GetOperator() { string? apiKey = Request.Headers["X-Boot-Admin-Key"].FirstOrDefault(); - if (!_stateService.IsAdminAuthorized(apiKey)) + if (!CanViewOperatorDiagnostics( + _poolConfig, + _stateService.IsAdminAuthorized(apiKey))) { return Unauthorized(new { status = "rejected", reason = "Missing or invalid admin key" }); } @@ -70,7 +75,8 @@ public IActionResult GetOperator() public IActionResult GetDiagram() { Response.Headers.CacheControl = "no-store"; - return Ok(_dashboard.BuildDiagram(includeOperatorDetails: false)); + return Ok(_dashboard.BuildDiagram( + includeOperatorDetails: _poolConfig.TrustedPrivateDashboardEnabled)); } [EnableRateLimiting("dashboard-read")] @@ -78,7 +84,10 @@ public IActionResult GetDiagram() public IActionResult GetDiagramEvents([FromQuery] long after = 0, [FromQuery] int limit = 256) { Response.Headers.CacheControl = "no-store"; - return Ok(_visualization.Read(Math.Max(0, after), limit, redacted: true)); + return Ok(_visualization.Read( + Math.Max(0, after), + limit, + redacted: !_poolConfig.TrustedPrivateDashboardEnabled)); } [EnableRateLimiting("dashboard-read")] @@ -88,7 +97,10 @@ public IActionResult GetDiagramHistory( [FromQuery] int limit = 256) { Response.Headers.CacheControl = "no-store"; - return Ok(_dashboard.BuildDiagramHistory(window, limit, includeOperatorDetails: false)); + return Ok(_dashboard.BuildDiagramHistory( + window, + limit, + includeOperatorDetails: _poolConfig.TrustedPrivateDashboardEnabled)); } [EnableRateLimiting("dashboard-read")] @@ -158,6 +170,7 @@ public IActionResult GetSchema() => authentication = new { operatorHeader = "X-Boot-Admin-Key", + trustedPrivateDashboard = _poolConfig.TrustedPrivateDashboardEnabled, storageGuidance = "Keep operator credentials in memory only." } }); @@ -165,6 +178,11 @@ public IActionResult GetSchema() => private bool IsAdminAuthorized() { string? apiKey = Request.Headers["X-Boot-Admin-Key"].FirstOrDefault(); - return _stateService.IsAdminAuthorized(apiKey); + return CanViewOperatorDiagnostics( + _poolConfig, + _stateService.IsAdminAuthorized(apiKey)); } + + internal static bool CanViewOperatorDiagnostics(PoolConfig config, bool adminAuthorized) => + config.TrustedPrivateDashboardEnabled || adminAuthorized; } diff --git a/boot_portal/Models/DashboardModels.cs b/boot_portal/Models/DashboardModels.cs index 0da31e7..174a618 100644 --- a/boot_portal/Models/DashboardModels.cs +++ b/boot_portal/Models/DashboardModels.cs @@ -141,6 +141,7 @@ public sealed class DashboardCapabilitiesDto public bool WebUiEnabled { get; set; } public bool LegacyUiEnabled { get; set; } public bool OperatorApiAvailable { get; set; } + public bool OperatorAccessImplicit { get; set; } public bool AddressLookupAvailable { get; set; } = true; public bool WorkRateTelemetryAvailable { get; set; } = true; public bool PulseTelemetryAvailable { get; set; } = true; diff --git a/boot_portal/Models/PoolConfig.cs b/boot_portal/Models/PoolConfig.cs index 5a1bfa1..11fdc41 100644 --- a/boot_portal/Models/PoolConfig.cs +++ b/boot_portal/Models/PoolConfig.cs @@ -101,6 +101,9 @@ public class PoolConfig [JsonPropertyName("public_operator_diagnostics_enabled")] public bool PublicOperatorDiagnosticsEnabled { get; set; } = false; + [JsonPropertyName("trusted_private_dashboard_enabled")] + public bool TrustedPrivateDashboardEnabled { get; set; } = false; + [JsonPropertyName("max_state_bundle_history")] public int MaxStateBundleHistory { get; set; } = 8; diff --git a/boot_portal/Pages/Setup.cshtml b/boot_portal/Pages/Setup.cshtml index c62ea79..1d1886a 100644 --- a/boot_portal/Pages/Setup.cshtml +++ b/boot_portal/Pages/Setup.cshtml @@ -37,6 +37,20 @@ Locked payout address @Model.SavedAddress + @if (Model.NativeSv2Enabled) + { +
+ Next: connect a native Stratum V2 miner +

Your miner endpoint

+ @Model.NativeSv2Url +
+
Host
@Model.NativeSv2Host
+
Port
@Model.NativeSv2Port
+
Username
Your payout address or worker label
+
+

If @Model.NativeSv2Host does not resolve from your miner, use this Umbrel device's LAN IP with port @Model.NativeSv2Port.

+
+ } @if (Model.AutomaticRestart) {

Waiting for the node to become ready. This page will open the dashboard automatically.

diff --git a/boot_portal/Pages/Setup.cshtml.cs b/boot_portal/Pages/Setup.cshtml.cs index c8fe0f7..3bfcdd0 100644 --- a/boot_portal/Pages/Setup.cshtml.cs +++ b/boot_portal/Pages/Setup.cshtml.cs @@ -31,6 +31,30 @@ public sealed class SetupModel( public string BitcoinNetwork => BitcoinScript.NormalizeNetwork(_poolConfig.BitcoinNetwork); + public bool NativeSv2Enabled => _poolConfig.NativeSv2Enabled; + + public string NativeSv2Host + { + get + { + if (!string.IsNullOrWhiteSpace(_poolConfig.NativeSv2PublicHost)) + { + return _poolConfig.NativeSv2PublicHost.Trim(); + } + + string requestHost = Request.Host.Host; + return string.IsNullOrWhiteSpace(requestHost) || + requestHost.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + requestHost.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) + ? "umbrel.local" + : requestHost; + } + } + + public int NativeSv2Port => _poolConfig.NativeSv2PublicPort; + + public string NativeSv2Url => $"stratum2+noise://{NativeSv2Host}:{NativeSv2Port}"; + public IActionResult OnGet() { if (_setupState.OperationalAtStartup) diff --git a/boot_portal/Services/DashboardReadModelService.cs b/boot_portal/Services/DashboardReadModelService.cs index 653bdda..e0c7ff4 100644 --- a/boot_portal/Services/DashboardReadModelService.cs +++ b/boot_portal/Services/DashboardReadModelService.cs @@ -125,6 +125,7 @@ public DashboardSummaryDto BuildSummary(string? windowKey) OperatorApiAvailable = _poolConfig.EnableAdminApi && !string.IsNullOrWhiteSpace(_poolConfig.AdminApiKey), + OperatorAccessImplicit = _poolConfig.TrustedPrivateDashboardEnabled, WatchtowerAvailable = false } }; diff --git a/boot_portal/ui/AGENTS.md b/boot_portal/ui/AGENTS.md index f3bf23d..b2b3a30 100644 --- a/boot_portal/ui/AGENTS.md +++ b/boot_portal/ui/AGENTS.md @@ -68,8 +68,11 @@ exports. Miner-facing endpoints are explicit non-secret summary data. Packaged nodes set `native_sv2_enabled`, `native_sv2_public_host`, and `native_sv2_public_port`; when the host is blank the dashboard suggests the browser hostname and explains -that a reachable LAN hostname or IP may be substituted. Never infer that the -operator API exists merely because the UI is private behind an appliance proxy. +that a reachable LAN hostname or IP may be substituted. Appliance wrappers may +explicitly set `trusted_private_dashboard_enabled` when their authenticated +proxy is the access boundary. That flag unlocks read-only diagnostics only; +destructive admin routes still require the admin key. It defaults to false and +must never be inferred merely because a UI appears private. The production map owns one SignalR connection per tab. Invalidation refreshes are coalesced and single-flight: journal reads run at most once per second, diff --git a/boot_portal/ui/src/App.tsx b/boot_portal/ui/src/App.tsx index 8ae80dc..2f3c303 100644 --- a/boot_portal/ui/src/App.tsx +++ b/boot_portal/ui/src/App.tsx @@ -1,7 +1,7 @@ import { FormEvent, startTransition, useEffect, useState } from "react"; import { dashboardApi } from "./api"; import { StatusDot } from "./components/Primitives"; -import { MinerConnectionPanel } from "./components/MinerConnection"; +import { MinerConnectionPanel, nativeSv2Url } from "./components/MinerConnection"; import { SystemMap } from "./components/SystemMap"; import { formatAge } from "./format"; import { useDashboard } from "./hooks/useDashboard"; @@ -63,6 +63,7 @@ function MapApp() { const summary = live.summary; const testnet = summary.node.bitcoinNetwork !== "mainnet"; + const operatorAccessImplicit = summary.capabilities.operatorAccessImplicit; return (
@@ -85,7 +86,7 @@ function MapApp() { Details {summary.mining?.nativeSv2.enabled ? ( - ) : null} @@ -97,7 +98,7 @@ function MapApp() { > {theme === "dark" ? "○" : "●"} - {summary.capabilities.operatorApiAvailable ? ( + {!operatorAccessImplicit && summary.capabilities.operatorApiAvailable ? ( + + ) : null} +
@@ -224,6 +235,7 @@ function DetailsApp() { const summary = dashboard.summary; const testnet = summary.node.bitcoinNetwork !== "mainnet"; + const operatorAccessImplicit = summary.capabilities.operatorAccessImplicit; const context: DashboardModuleContext = { summary, history: dashboard.history, @@ -269,7 +281,7 @@ function DetailsApp() { > {theme === "dark" ? "○" : "●"} - {summary.capabilities.operatorApiAvailable ? ( + {!operatorAccessImplicit && summary.capabilities.operatorApiAvailable ? (