chore: snapshot release workspace

This commit is contained in:
archipelago
2026-06-12 03:00:15 -04:00
parent 6a30ff11bd
commit d6f108d818
76 changed files with 792 additions and 3613 deletions

View File

@@ -227,6 +227,9 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -507,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}

View File

@@ -659,8 +659,8 @@ async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredential
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) => value,
None => {
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
@@ -729,6 +729,24 @@ fn generate_rpcauth(username: &str, password: &str) -> String {
format!("{username}:{salt_hex}${hash_hex}")
}
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {

View File

@@ -731,7 +731,6 @@ fn health_probe_url_for_app(app_id: &str) -> Option<String> {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"dwn" => 3100,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,

View File

@@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
@@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
.output()
.await;
let mut args = vec![
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args([
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
.args(args)
.output()
.await
.context("Failed to run nmcli wifi profile create")?;

View File

@@ -13,18 +13,33 @@ impl RpcHandler {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("LND could not generate an address: {}", error);
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"LND could not generate a Bitcoin address ({}): {}",
status,
message
);
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
}
let address = body
@@ -548,3 +563,35 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
#[cfg(test)]
mod tests {
use super::lnd_error_message;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
}

View File

@@ -312,11 +312,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
@@ -360,10 +355,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -384,7 +379,6 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
@@ -789,11 +783,9 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -1129,18 +1121,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(

View File

@@ -133,6 +133,10 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
}
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)

View File

@@ -276,7 +276,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
// Recommended: enhanced functionality
"fedimint" | "fedimint-gateway" => "recommended",
@@ -518,13 +517,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
icon: "/assets/img/app-icons/dwn.svg".to_string(),
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
tier: "",
},
"tor" | "archy-tor" => AppMetadata {
title: "Tor".to_string(),
description: "Anonymous overlay network for privacy".to_string(),

View File

@@ -187,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),

View File

@@ -66,7 +66,9 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
@@ -109,6 +111,7 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
@@ -119,8 +122,9 @@ async fn merge_transitive_peers(
let mut refreshed = 0u32;
for hint in hints {
// Don't import our own DID (a peer advertising us back).
if hint.did == source_did {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
@@ -359,4 +363,69 @@ mod tests {
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("trusted transitive peer should be added");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
assert_eq!(peer.trust_level, TrustLevel::Trusted);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}

View File

@@ -71,7 +71,7 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count, containers) =
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
@@ -98,6 +98,10 @@ async fn build_telemetry_report(
.count();
(
id,
data.server_info
.name
.clone()
.filter(|n| !n.trim().is_empty()),
data.server_info.version.clone(),
data.package_data.len(),
running,
@@ -107,6 +111,7 @@ async fn build_telemetry_report(
} else {
(
"unknown".to_string(),
None,
"unknown".to_string(),
0,
0,
@@ -125,6 +130,8 @@ async fn build_telemetry_report(
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
let hostname = system_hostname().await;
let server_url = local_server_url(data_dir).await;
// Latest metrics snapshot
let latest = store.latest().await;
@@ -166,6 +173,9 @@ async fn build_telemetry_report(
Ok(serde_json::json!({
"node_id": node_id,
"node_name": node_name,
"hostname": hostname,
"server_url": server_url,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -181,6 +191,35 @@ async fn build_telemetry_report(
}))
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
let _ = data_dir;
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
.to_string();
Some(format!("https://{ip}"))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()

View File

@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info};
use tracing::{debug, info, warn};
/// Live download progress counters. Updated by download_component_resumable
/// as bytes arrive and read by the update.status RPC so the UI can show
@@ -502,6 +502,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
.context("Reading update state")?;
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
let mut changed = false;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
@@ -517,11 +519,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
changed = true;
}
// `update_in_progress` means a manifest OTA is downloaded and staged,
// ready for apply. Older git/self-build update paths could leave this
// flag stuck true without a staging directory, which traps the UI in an
// unrecoverable state. Heal that on every state load.
if state.update_in_progress && !has_staged_update(data_dir).await {
warn!(
staging = %data_dir.join("update-staging").display(),
"Clearing stale update_in_progress without staged OTA files"
);
state.update_in_progress = false;
changed = true;
}
if changed {
save_state(data_dir, &state).await?;
}
Ok(state)
}
async fn has_staged_update(data_dir: &Path) -> bool {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
let path = data_dir.join(UPDATE_STATE_FILE);
let data = serde_json::to_string_pretty(state)?;
@@ -1764,6 +1791,11 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@@ -1800,6 +1832,22 @@ mod tests {
assert!(loaded.available_update.is_none());
}
#[tokio::test]
async fn test_load_state_clears_stale_in_progress_without_staging() {
let dir = tempfile::tempdir().unwrap();
let state = UpdateState {
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert!(!loaded.update_in_progress);
let persisted = load_state(dir.path()).await.unwrap();
assert!(!persisted.update_in_progress);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -123,7 +123,6 @@ impl PodmanClient {
"immich_server" | "immich" => "http://localhost:2283",
"nginx-proxy-manager" => "http://localhost:8081",
"fedimint-gateway" => "http://localhost:8176",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",