feat: add Bitcoin Core Tor IBD gossip control
This commit is contained in:
@@ -317,6 +317,27 @@ FEATURE_REGISTRY = [
|
|||||||
"conflicts_with": [],
|
"conflicts_with": [],
|
||||||
"port_requirements": [],
|
"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",
|
"id": "sshd",
|
||||||
"name": "SSH Remote Access",
|
"name": "SSH Remote Access",
|
||||||
@@ -358,6 +379,7 @@ FEATURE_SERVICE_MAP = {
|
|||||||
"haven": "haven-relay.service",
|
"haven": "haven-relay.service",
|
||||||
"element-calling": "livekit.service",
|
"element-calling": "livekit.service",
|
||||||
"mempool": "mempool.service",
|
"mempool": "mempool.service",
|
||||||
|
"bitcoin-tor-gossip": None,
|
||||||
"btcpay-web": "btcpayserver.service",
|
"btcpay-web": "btcpayserver.service",
|
||||||
"nwc-wallets": "albyhub.service",
|
"nwc-wallets": "albyhub.service",
|
||||||
"sshd": "sshd.service",
|
"sshd": "sshd.service",
|
||||||
@@ -431,7 +453,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
|||||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"rdp", "sshd"},
|
"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] = {
|
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||||
@@ -2058,14 +2080,24 @@ def _migrate_strip_deprecated_features() -> None:
|
|||||||
# ── Feature status helpers ─────────────────────────────────────────
|
# ── Feature status helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
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.
|
"""Check whether a feature is enabled in the evaluated Hub configuration.
|
||||||
Returns True/False if found, None if the feature has no mapped service."""
|
|
||||||
|
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":
|
if feature_id == "btcpay-web":
|
||||||
return False # Default off in Node role; only on via explicit hub toggle
|
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)
|
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
return None
|
return None
|
||||||
cfg = load_config()
|
|
||||||
for svc in cfg.get("services", []):
|
for svc in cfg.get("services", []):
|
||||||
if svc.get("unit") == unit:
|
if svc.get("unit") == unit:
|
||||||
return svc.get("enabled", False)
|
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", []),
|
"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 = {
|
service_detail: dict = {
|
||||||
"name": entry.get("name", ""),
|
"name": entry.get("name", ""),
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
@@ -3568,6 +3631,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"external_ip": external_ip,
|
"external_ip": external_ip,
|
||||||
"internal_ip": internal_ip,
|
"internal_ip": internal_ip,
|
||||||
"feature": feature_entry,
|
"feature": feature_entry,
|
||||||
|
"related_features": related_features,
|
||||||
}
|
}
|
||||||
if sync_ibd is not None:
|
if sync_ibd is not None:
|
||||||
service_detail["sync_ibd"] = sync_ibd
|
service_detail["sync_ibd"] = sync_ibd
|
||||||
@@ -4190,8 +4254,10 @@ async def api_features():
|
|||||||
|
|
||||||
role = load_config().get("role", "server_plus_desktop")
|
role = load_config().get("role", "server_plus_desktop")
|
||||||
allowed_features = ROLE_FEATURES.get(role)
|
allowed_features = ROLE_FEATURES.get(role)
|
||||||
registry = FEATURE_REGISTRY if allowed_features is None else [
|
registry = [
|
||||||
f for f in FEATURE_REGISTRY if f["id"] in allowed_features
|
f for f in FEATURE_REGISTRY
|
||||||
|
if not f.get("modal_only")
|
||||||
|
and (allowed_features is None or f["id"] in allowed_features)
|
||||||
]
|
]
|
||||||
|
|
||||||
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)
|
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
if req.enabled:
|
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
|
# Element-calling requires matrix domain
|
||||||
if req.feature == "element-calling":
|
if req.feature == "element-calling":
|
||||||
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
||||||
|
|||||||
@@ -365,6 +365,45 @@
|
|||||||
color: var(--text-dim);
|
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 {
|
.feature-conflict-warning {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
|
|||||||
function handleFeatureToggle(feat, newEnabled) {
|
function handleFeatureToggle(feat, newEnabled) {
|
||||||
if (!newEnabled) {
|
if (!newEnabled) {
|
||||||
// Disable: ask confirmation
|
// 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(
|
openFeatureConfirm(
|
||||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
disableMessage,
|
||||||
function() { performFeatureToggle(feat.id, false, {}); }
|
function() { performFeatureToggle(feat.id, false, {}); }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -403,7 +406,10 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
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);
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
proceedAfterConflictCheck();
|
proceedAfterConflictCheck();
|
||||||
|
|||||||
@@ -918,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>');
|
'</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") {
|
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
'<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 restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||||
if (restartBtn && restartResult) {
|
if (restartBtn && restartResult) {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
|
|
||||||
services.bitcoind = {
|
services.bitcoind = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
# Keep the normal loopback P2P socket available for local clients such as
|
||||||
|
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
||||||
|
# clearnet or LAN listener. When the existing bitcoind onion service is
|
||||||
|
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
||||||
|
listen = true;
|
||||||
package = pkgs.bitcoind;
|
package = pkgs.bitcoind;
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
txindex = true;
|
txindex = true;
|
||||||
@@ -16,7 +21,13 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind.enable = true;
|
nix-bitcoin.onionServices.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
# This is a locally vendored option namespace, not an upstream dependency.
|
||||||
|
# The onion listener remains available to peers that already know it;
|
||||||
|
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
||||||
|
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
nix-bitcoin.onionServices.electrs.enable = true;
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
nix-bitcoin.onionServices.rtl.enable = true;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
haven = lib.mkForce false;
|
haven = lib.mkForce false;
|
||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
|
bitcoin-tor-gossip = lib.mkForce false;
|
||||||
"nwc-wallets" = lib.mkForce false;
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
|
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
|
||||||
# Compatibility shim for Hub-managed settings from releases where Core
|
# Compatibility shim for Hub-managed settings from releases where Core
|
||||||
# was an optional replacement for the default node. Core is now always
|
# was an optional replacement for the default node. Core is now always
|
||||||
# selected when the Bitcoin service is enabled.
|
# selected when the Bitcoin service is enabled.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ let
|
|||||||
# ── Bitcoin Base ────────────────────────────────────────────
|
# ── Bitcoin Base ────────────────────────────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||||
@@ -122,6 +122,9 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
feature_states = {
|
||||||
|
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
sovran_version = sovranVersion;
|
sovran_version = sovranVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
|
||||||
|
|
||||||
|
These tests intentionally avoid importing the FastAPI application so they can run
|
||||||
|
in the repository's lightweight test environment without NixOS service access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
|
||||||
|
return source.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_assignment(source: str, name: str):
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
raise AssertionError(f"Assignment {name} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
|
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn("listen = true;", ecosystem)
|
||||||
|
self.assertIn("peerbloomfilters=1", ecosystem)
|
||||||
|
|
||||||
|
def test_gossip_is_opt_in(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn(
|
||||||
|
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
||||||
|
ecosystem,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
hub = _read("modules", "core", "sovran-hub.nix")
|
||||||
|
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
|
||||||
|
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
|
||||||
|
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
|
||||||
|
|
||||||
|
def test_feature_is_modal_only_and_explains_risk(self):
|
||||||
|
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
|
||||||
|
self.assertTrue(feature["modal_only"])
|
||||||
|
self.assertIn("bitcoin-service", feature["requires"])
|
||||||
|
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
|
||||||
|
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
|
||||||
|
|
||||||
|
def test_backend_rejects_gossip_without_bitcoin_service(self):
|
||||||
|
self.assertIn(
|
||||||
|
"Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||||
|
self.server_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_modal_renders_and_controls_the_option(self):
|
||||||
|
frontend = _read(
|
||||||
|
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
|
||||||
|
)
|
||||||
|
self.assertIn("Tor IBD Service Advertising", frontend)
|
||||||
|
self.assertIn("svc-detail-related-feature-btn", frontend)
|
||||||
|
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user