feat: add Bitcoin Core Tor IBD gossip control
This commit is contained in:
@@ -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")):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -918,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>');
|
||||
}
|
||||
|
||||
// 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 = '<ul class="svc-detail-option-list">' +
|
||||
optionFeat.details.map(function(detail) {
|
||||
return '<li>' + escHtml(detail) + '</li>';
|
||||
}).join("") +
|
||||
'</ul>';
|
||||
}
|
||||
|
||||
var optionAvailable = optionFeat.available !== false;
|
||||
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
|
||||
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
|
||||
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
|
||||
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||
if (!optionAvailable) {
|
||||
optionStatusLabel = "Bitcoin Core is not enabled";
|
||||
optionButtonLabel = "Enable Bitcoin Core First";
|
||||
optionButtonCls = "btn btn-close-modal";
|
||||
}
|
||||
|
||||
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
|
||||
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
|
||||
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
|
||||
detailsHtml +
|
||||
'<div class="svc-detail-option-privacy">' +
|
||||
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
|
||||
'</div>' +
|
||||
'<div class="svc-detail-addon-row">' +
|
||||
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
|
||||
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>');
|
||||
});
|
||||
|
||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||
@@ -978,6 +1026,18 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
}
|
||||
}
|
||||
|
||||
var relatedFeatureButtons = $credsBody.querySelectorAll(".svc-detail-related-feature-btn");
|
||||
relatedFeatureButtons.forEach(function(button) {
|
||||
button.addEventListener("click", function() {
|
||||
if (button.disabled) return;
|
||||
var featureId = button.getAttribute("data-feature-id");
|
||||
var relatedFeat = relatedFeatures.find(function(f) { return f.id === featureId; });
|
||||
if (!relatedFeat) return;
|
||||
closeCredsModal();
|
||||
handleFeatureToggle(relatedFeat, !relatedFeat.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||
if (restartBtn && restartResult) {
|
||||
|
||||
Reference in New Issue
Block a user