diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 687d17a..2a1ef0a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -317,6 +317,27 @@ FEATURE_REGISTRY = [ "conflicts_with": [], "port_requirements": [], }, + { + "id": "bitcoin-tor-gossip", + "name": "Advertise Tor IBD Node", + "description": "Advertise this Bitcoin Core node's onion address through Bitcoin peer gossip so more Tor-capable nodes can discover it and request blocks.", + "details": [ + "Your Tor IBD listener remains available whether or not advertising is enabled.", + "Enabling this announces only the node's .onion P2P address; it does not publish your home IP address.", + "Other Tor nodes can discover your node and request historical blocks while performing Initial Block Download (IBD).", + "No clearnet port or router port forwarding is opened.", + "Serving additional IBD peers can use significant upload bandwidth.", + ], + "category": "bitcoin", + "modal_only": True, + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + "requires": ["bitcoin-service"], + "port_requirements": [], + }, { "id": "sshd", "name": "SSH Remote Access", @@ -358,6 +379,7 @@ FEATURE_SERVICE_MAP = { "haven": "haven-relay.service", "element-calling": "livekit.service", "mempool": "mempool.service", + "bitcoin-tor-gossip": None, "btcpay-web": "btcpayserver.service", "nwc-wallets": "albyhub.service", "sshd": "sshd.service", @@ -431,7 +453,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = { "server_plus_desktop": None, "desktop": {"rdp", "sshd"}, - "node": {"rdp", "mempool", "btcpay-web", "nwc-wallets", "sshd"}, + "node": {"rdp", "bitcoin-tor-gossip", "mempool", "btcpay-web", "nwc-wallets", "sshd"}, } SERVICE_DESCRIPTIONS: dict[str, str] = { @@ -2058,14 +2080,24 @@ def _migrate_strip_deprecated_features() -> None: # ── Feature status helpers ───────────────────────────────────────── def _is_feature_enabled_in_config(feature_id: str) -> bool | None: - """Check if a feature's service appears as enabled in the running config.json. - Returns True/False if found, None if the feature has no mapped service.""" + """Check whether a feature is enabled in the evaluated Hub configuration. + + Most features map directly to a systemd service. Modal-only settings are + represented separately in ``config.json``. Returns ``None`` only when no + evaluated state is available. + """ if feature_id == "btcpay-web": return False # Default off in Node role; only on via explicit hub toggle + + cfg = load_config() + + if feature_id == "bitcoin-tor-gossip": + state = cfg.get("feature_states", {}).get(feature_id) + return bool(state) if state is not None else None + unit = FEATURE_SERVICE_MAP.get(feature_id) if unit is None: return None - cfg = load_config() for svc in cfg.get("services", []): if svc.get("unit") == unit: return svc.get("enabled", False) @@ -3546,6 +3578,37 @@ async def api_service_detail(unit: str, icon: str | None = None): "port_requirements": feat_meta.get("port_requirements", []), } + # Modal-only settings related to this service. These are intentionally not + # rendered as standalone feature cards: the user encounters them in the + # context where their consequences are easiest to understand. + related_features: list[dict] = [] + if icon == "bitcoin-core": + related_id = "bitcoin-tor-gossip" + related_meta = next((f for f in FEATURE_REGISTRY if f["id"] == related_id), None) + if related_meta is not None: + if related_id in overrides: + related_enabled = bool(overrides[related_id]) + else: + config_state = _is_feature_enabled_in_config(related_id) + related_enabled = bool(config_state) if config_state is not None else False + related_features.append({ + "id": related_id, + "name": related_meta["name"], + "description": related_meta["description"], + "details": related_meta.get("details", []), + "category": related_meta["category"], + "enabled": related_enabled, + "available": bool(enabled), + "needs_domain": False, + "domain_configured": True, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": related_meta.get("conflicts_with", []), + "requires": related_meta.get("requires", []), + "port_requirements": [], + }) + service_detail: dict = { "name": entry.get("name", ""), "unit": unit, @@ -3568,6 +3631,7 @@ async def api_service_detail(unit: str, icon: str | None = None): "external_ip": external_ip, "internal_ip": internal_ip, "feature": feature_entry, + "related_features": related_features, } if sync_ibd is not None: service_detail["sync_ibd"] = sync_ibd @@ -4190,8 +4254,10 @@ async def api_features(): role = load_config().get("role", "server_plus_desktop") allowed_features = ROLE_FEATURES.get(role) - registry = FEATURE_REGISTRY if allowed_features is None else [ - f for f in FEATURE_REGISTRY if f["id"] in allowed_features + registry = [ + f for f in FEATURE_REGISTRY + if not f.get("modal_only") + and (allowed_features is None or f["id"] in allowed_features) ] features = [] @@ -4265,6 +4331,22 @@ async def api_features_toggle(req: FeatureToggleRequest): features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides) if req.enabled: + # Onion-address advertising is only meaningful while the Bitcoin Core + # service is enabled. The control is shown in that service's modal, but + # enforce the dependency server-side as well. + if req.feature == "bitcoin-tor-gossip": + bitcoin_core_enabled = any( + svc.get("unit") == "bitcoind.service" + and svc.get("icon") == "bitcoin-core" + and bool(svc.get("enabled", False)) + for svc in load_config().get("services", []) + ) + if not bitcoin_core_enabled: + raise HTTPException( + status_code=400, + detail="Enable the Bitcoin service before advertising its Tor IBD service.", + ) + # Element-calling requires matrix domain if req.feature == "element-calling": if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")): diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index 91589fc..5c0a4e9 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -365,6 +365,45 @@ color: var(--text-dim); } +.svc-detail-option-card { + padding: 16px; + background: rgba(94, 173, 138, 0.06); + border: 1px solid rgba(94, 173, 138, 0.24); + border-radius: 10px; +} + +.svc-detail-option-list { + margin: 10px 0 0; + padding-left: 20px; + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.55; +} + +.svc-detail-option-list li { + margin-bottom: 5px; +} + +.svc-detail-option-privacy { + margin-top: 12px; + padding: 10px 12px; + border-left: 3px solid var(--accent-color); + background: rgba(94, 173, 138, 0.08); + border-radius: 6px; + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.5; +} + +.svc-detail-option-privacy strong { + color: var(--accent-color); +} + +.svc-detail-related-feature-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .feature-conflict-warning { margin-top: 8px; margin-bottom: 8px; diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 6060973..e27ebe1 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) { function handleFeatureToggle(feat, newEnabled) { if (!newEnabled) { // Disable: ask confirmation + var disableMessage = (feat.id === "bitcoin-tor-gossip") + ? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?" + : "This will disable " + feat.name + ". The system will rebuild. Continue?"; openFeatureConfirm( - "This will disable " + feat.name + ". The system will rebuild. Continue?", + disableMessage, function() { performFeatureToggle(feat.id, false, {}); } ); return; @@ -403,7 +406,10 @@ function handleFeatureToggle(feat, newEnabled) { openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); } - if (conflictNames.length > 0) { + if (feat.id === "bitcoin-tor-gossip") { + var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?"; + openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck); + } else if (conflictNames.length > 0) { openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck); } else { proceedAfterConflictCheck(); diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 2fba031..620194e 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -918,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) { ''); } + // Section G: Contextual feature options. The Tor IBD advertising control is + // deliberately shown only inside the Bitcoin Core modal so its bandwidth + // and privacy implications are explained before the user enables it. + var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : []; + relatedFeatures.forEach(function(optionFeat) { + // Keep the shared feature state current for the standard rebuild flow. + if (!_featuresData) { + _featuresData = { features: [optionFeat], ssl_email_configured: false }; + } else { + var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; }); + if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat; + else _featuresData.features.push(optionFeat); + } + + var detailsHtml = ""; + if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) { + detailsHtml = '
' + escHtml(optionFeat.description || "") + '
' + + detailsHtml + + '