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
If @Model.SavedAddress
+ @if (Model.NativeSv2Enabled)
+ {
+ Your miner endpoint
+ @Model.NativeSv2Url
+
+
+ @Model.NativeSv2Host does not resolve from your miner, use this Umbrel device's LAN IP with port @Model.NativeSv2Port.
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 ({nativeSv2Url(summary)}
+