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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions boot.tests/SecurityHardeningTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
4 changes: 3 additions & 1 deletion boot_portal/Controllers/BootNetworkController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,9 @@ public IActionResult TombstonePeer([FromBody] BootPeerTombstoneRequest request)
}

private bool CanViewOperatorDiagnostics() =>
_poolConfig.PublicOperatorDiagnosticsEnabled || IsAdminAuthorized();
_poolConfig.PublicOperatorDiagnosticsEnabled ||
_poolConfig.TrustedPrivateDashboardEnabled ||
IsAdminAuthorized();

private bool IsAdminAuthorized()
{
Expand Down
30 changes: 24 additions & 6 deletions boot_portal/Controllers/DashboardController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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" });
}
Expand All @@ -70,15 +75,19 @@ 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")]
[HttpGet("diagram/events")]
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")]
Expand All @@ -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")]
Expand Down Expand Up @@ -158,13 +170,19 @@ public IActionResult GetSchema() =>
authentication = new
{
operatorHeader = "X-Boot-Admin-Key",
trustedPrivateDashboard = _poolConfig.TrustedPrivateDashboardEnabled,
storageGuidance = "Keep operator credentials in memory only."
}
});

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;
}
1 change: 1 addition & 0 deletions boot_portal/Models/DashboardModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions boot_portal/Models/PoolConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
14 changes: 14 additions & 0 deletions boot_portal/Pages/Setup.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@
<span>Locked payout address</span>
<code>@Model.SavedAddress</code>
</div>
@if (Model.NativeSv2Enabled)
{
<section class="connection-summary" aria-labelledby="sv2-heading">
<span class="connection-label">Next: connect a native Stratum V2 miner</span>
<h2 id="sv2-heading">Your miner endpoint</h2>
<code>@Model.NativeSv2Url</code>
<dl>
<div><dt>Host</dt><dd>@Model.NativeSv2Host</dd></div>
<div><dt>Port</dt><dd>@Model.NativeSv2Port</dd></div>
<div><dt>Username</dt><dd>Your payout address or worker label</dd></div>
</dl>
<p>If <code>@Model.NativeSv2Host</code> does not resolve from your miner, use this Umbrel device's LAN IP with port @Model.NativeSv2Port.</p>
</section>
}
@if (Model.AutomaticRestart)
{
<p class="safety-note" id="restart-status">Waiting for the node to become ready. This page will open the dashboard automatically.</p>
Expand Down
24 changes: 24 additions & 0 deletions boot_portal/Pages/Setup.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions boot_portal/Services/DashboardReadModelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ public DashboardSummaryDto BuildSummary(string? windowKey)
OperatorApiAvailable =
_poolConfig.EnableAdminApi &&
!string.IsNullOrWhiteSpace(_poolConfig.AdminApiKey),
OperatorAccessImplicit = _poolConfig.TrustedPrivateDashboardEnabled,
WatchtowerAvailable = false
}
};
Expand Down
7 changes: 5 additions & 2 deletions boot_portal/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 17 additions & 5 deletions boot_portal/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -63,6 +63,7 @@ function MapApp() {

const summary = live.summary;
const testnet = summary.node.bitcoinNetwork !== "mainnet";
const operatorAccessImplicit = summary.capabilities.operatorAccessImplicit;
return (
<div className={testnet ? "app map-app app-testnet" : "app map-app"}>
<header className="truth-bar map-truth-bar">
Expand All @@ -85,7 +86,7 @@ function MapApp() {
</span>
<a className="details-link" href="/details">Details</a>
{summary.mining?.nativeSv2.enabled ? (
<button type="button" className="operator-button" onClick={() => setMiningOpen(true)}>
<button type="button" className="connect-button" onClick={() => setMiningOpen(true)}>
Connect miner
</button>
) : null}
Expand All @@ -97,7 +98,7 @@ function MapApp() {
>
{theme === "dark" ? "○" : "●"}
</button>
{summary.capabilities.operatorApiAvailable ? (
{!operatorAccessImplicit && summary.capabilities.operatorApiAvailable ? (
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
Expand All @@ -115,6 +116,16 @@ function MapApp() {
</div>
) : null}

{summary.mining?.nativeSv2.enabled ? (
<section className="miner-quick-connect" aria-label="Stratum V2 connection">
<div>
<span>Connect your miner</span>
<code>{nativeSv2Url(summary)}</code>
</div>
<button type="button" onClick={() => setMiningOpen(true)}>Connection details</button>
</section>
) : null}

<main className="map-shell">
<SystemMap
diagram={live.diagram}
Expand All @@ -123,7 +134,7 @@ function MapApp() {
onHistoryWindowChange={live.setWindowKey}
activeEvent={live.activeEvent}
onEventComplete={live.acknowledgeEvent}
operatorUnlocked={Boolean(adminKey)}
operatorUnlocked={operatorAccessImplicit || Boolean(adminKey)}
/>
</main>

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -269,7 +281,7 @@ function DetailsApp() {
>
{theme === "dark" ? "○" : "●"}
</button>
{summary.capabilities.operatorApiAvailable ? (
{!operatorAccessImplicit && summary.capabilities.operatorApiAvailable ? (
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
Expand Down
2 changes: 1 addition & 1 deletion boot_portal/ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const dashboardApi = {
request<DashboardHistory>(`/api/dashboard/v1/history?window=${window}`),
address: (address: string) =>
request<DashboardAddress>(`/api/dashboard/v1/address/${encodeURIComponent(address)}`),
operator: (adminKey: string) =>
operator: (adminKey?: string) =>
request<DashboardOperator>("/api/dashboard/v1/operator", adminKey),
diagram: (adminKey?: string) =>
adminKey
Expand Down
10 changes: 7 additions & 3 deletions boot_portal/ui/src/hooks/useDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ export function useDashboard(windowKey: WindowKey, adminKey: string) {

const refresh = useEffectEvent(async (includeHistory = false) => {
try {
const [summary, history, operator] = await Promise.all([
dashboardApi.summary(windowKey),
const summary = await dashboardApi.summary(windowKey);
const [history, operator] = await Promise.all([
includeHistory ? dashboardApi.history(windowKey) : Promise.resolve(state.history),
adminKey ? dashboardApi.operator(adminKey) : Promise.resolve(null)
summary.capabilities.operatorAccessImplicit
? dashboardApi.operator()
: adminKey
? dashboardApi.operator(adminKey)
: Promise.resolve(null)
]);
setState((current) => ({
...current,
Expand Down
2 changes: 1 addition & 1 deletion boot_portal/ui/src/modules/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ function ConsoleModule(context: DashboardModuleContext) {
result = context.operator.localMiningSources;
break;
case "latency":
if (!context.adminKey) throw new Error("Unlock operator view before requesting relay diagnostics.");
if (!context.operator) throw new Error("Operator diagnostics are not available on this node.");
result = await dashboardApi.raw("/api/network/peer-relay-latency?window=12h&limit=100", context.adminKey);
break;
case "connect":
Expand Down
Loading
Loading