Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38cb3dd252 | ||
|
|
50170b866e | ||
|
|
c91887a397 | ||
|
|
4fb200e938 | ||
|
|
5fd0d6c3c8 | ||
|
|
3ab7fb521a | ||
|
|
9e3ac9ba8f | ||
|
|
e2f83c0157 | ||
|
|
d5f709a3c3 | ||
|
|
710f576c77 | ||
|
|
c1d309f21f | ||
|
|
9c39243969 | ||
|
|
f25febf3bb | ||
|
|
0636d611e0 | ||
|
|
f13fdc6451 | ||
|
|
163bc3af01 | ||
|
|
ae12ff2517 | ||
|
|
cf4a8eef0e | ||
|
|
e5f8b5d789 | ||
|
|
aad6faa6d2 | ||
|
|
20edd31abb | ||
|
|
9a7331cead |
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
|
||||
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
|
||||
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
|
||||
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
|
||||
|
||||
## v1.7.105-alpha (2026-07-20)
|
||||
|
||||
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
|
||||
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
|
||||
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
|
||||
- The companion phone app no longer suggests installing the companion app from inside itself.
|
||||
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
|
||||
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
|
||||
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
|
||||
|
||||
## v1.7.104-alpha (2026-07-19)
|
||||
|
||||
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.104-alpha"
|
||||
version = "1.7.106-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.104-alpha"
|
||||
version = "1.7.106-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -438,7 +438,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("RPC error on {}: {}", rpc_req.method, e);
|
||||
// `{:#}` renders the whole anyhow context chain. Logging only the
|
||||
// outermost context threw away the actual cause: a peer-files
|
||||
// failure logged just "Failed to connect to peer", with the real
|
||||
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
|
||||
// the logs couldn't distinguish a dead peer from a slow circuit.
|
||||
// The client-facing message below stays `{}` so internals aren't leaked.
|
||||
error!("RPC error on {}: {:#}", rpc_req.method, e);
|
||||
let user_message = sanitize_error_message(&e.to_string());
|
||||
RpcResponse {
|
||||
result: None,
|
||||
|
||||
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
/// Services for known-but-uninstalled apps are hidden (issue #79).
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut apps = AppInstallState {
|
||||
known: Default::default(),
|
||||
installed: Default::default(),
|
||||
};
|
||||
for (id, pkg) in &data.package_data {
|
||||
apps.known.insert(id.clone());
|
||||
if pkg.installed.is_some() {
|
||||
apps.installed.insert(id.clone());
|
||||
}
|
||||
}
|
||||
let services = list_services(&config_dir, Some(&apps)).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
/// Which packages the node knows about and which are installed — used to
|
||||
/// hide hidden services for apps that aren't installed. ISO first-boot used
|
||||
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
|
||||
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
|
||||
/// never installed (issue #79).
|
||||
pub(super) struct AppInstallState {
|
||||
pub known: std::collections::HashSet<String>,
|
||||
pub installed: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
/// Package ids a Tor service name may correspond to. Service names predate
|
||||
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
|
||||
/// can map to several package ids.
|
||||
fn service_alias_candidates(name: &str) -> Vec<&str> {
|
||||
match name {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
|
||||
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
|
||||
}
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx", "electrs", "mempool-electrs"]
|
||||
}
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => {
|
||||
vec!["btcpay", "btcpay-server", "btcpayserver"]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
impl AppInstallState {
|
||||
/// A service is listed unless it names a known-but-uninstalled app.
|
||||
/// The node's own service, the content relay, and custom user-created
|
||||
/// services (names matching no catalog package) always show.
|
||||
fn service_visible(&self, name: &str) -> bool {
|
||||
if name == "archipelago" || name == "relay" {
|
||||
return true;
|
||||
}
|
||||
let candidates = service_alias_candidates(name);
|
||||
if !candidates.iter().any(|c| self.known.contains(*c)) {
|
||||
return true; // not an app — custom hidden service
|
||||
}
|
||||
candidates.iter().any(|c| self.installed.contains(*c))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_services(
|
||||
config_dir: &std::path::Path,
|
||||
apps: Option<&AppInstallState>,
|
||||
) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
|
||||
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
seen.insert(entry.name.clone());
|
||||
if !visible(&entry.name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(service_name.clone());
|
||||
if !visible(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
|
||||
@@ -437,6 +437,23 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "added": true, "npub": npub }))
|
||||
}
|
||||
|
||||
/// The host address a WireGuard peer should dial — prefer the configured
|
||||
/// host IP, then public-IP lookup, then first local address.
|
||||
async fn current_wg_endpoint_host(&self) -> String {
|
||||
if self.config.host_ip != "127.0.0.1" {
|
||||
return self.config.host_ip.clone();
|
||||
}
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
@@ -501,22 +518,7 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
|
||||
};
|
||||
|
||||
// Detect host IP — prefer config, then nvpn, then system detection
|
||||
let host_ip = if self.config.host_ip != "127.0.0.1" {
|
||||
self.config.host_ip.clone()
|
||||
} else {
|
||||
// Fallback: get public IP via external service
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
};
|
||||
let endpoint = format!("{}:51820", host_ip);
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||
@@ -667,15 +669,41 @@ impl RpcHandler {
|
||||
let content = tokio::fs::read_to_string(&peer_file)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
|
||||
let peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No config stored for peer '{}' — recreate the device to get a new QR code",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
// The stored Endpoint is the node's address at creation time; after
|
||||
// the node moves networks it points at a dead IP and the QR produces
|
||||
// a tunnel that can never connect. Refresh it to the current address.
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
let config: String = stored
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim_start().starts_with("Endpoint") {
|
||||
format!("Endpoint = {}", endpoint)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if config != stored {
|
||||
if let Some(obj) = peer.as_object_mut() {
|
||||
obj.insert("config".to_string(), config.clone().into());
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(&peer) {
|
||||
if tokio::fs::write(&peer_file, json).await.is_ok() {
|
||||
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let qr = qrcode::QrCode::new(config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
|
||||
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
|
||||
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
|
||||
/// write-probes are `podman exec`s into EVERY running container; running them
|
||||
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
|
||||
/// tick forever — a permanent conmon "Failed to create container" storm on
|
||||
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
|
||||
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
|
||||
/// steady-state one: sweep each container on the first pass after it appears,
|
||||
/// then at most once per hour.
|
||||
fn ownership_sweep_due(name: &str) -> bool {
|
||||
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
static LAST: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let Ok(mut map) = map.lock() else {
|
||||
return true;
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
match map.get(name) {
|
||||
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
|
||||
_ => {
|
||||
map.insert(name.to_string(), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
|
||||
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
|
||||
continue;
|
||||
}
|
||||
// Throttled: first pass after the container appears, then
|
||||
// hourly — not on every 30s tick (see ownership_sweep_due).
|
||||
if !ownership_sweep_due(&c.name) {
|
||||
continue;
|
||||
}
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
|
||||
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
/// Recover containers that were running before a crash.
|
||||
/// Attempts to start each container, logging success/failure.
|
||||
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
|
||||
// Snapshot entries can outlive their containers (removed while we were
|
||||
// down, or podman storage partially reset by an unclean poweroff).
|
||||
// `podman start` on those fails permanently, and recovery runs BEFORE the
|
||||
// server binds its port and notifies systemd ready — burning retries on
|
||||
// them pushed recovery past TimeoutStartSec and brick-looped the node
|
||||
// (killed mid-recovery → next boot sees a crash again, forever).
|
||||
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
|
||||
Some(existing) => {
|
||||
let (present, missing): (Vec<_>, Vec<_>) =
|
||||
containers.iter().partition(|r| existing.contains(&r.name));
|
||||
if !missing.is_empty() {
|
||||
warn!(
|
||||
"Skipping {} snapshot container(s) that no longer exist: {:?}",
|
||||
missing.len(),
|
||||
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
present
|
||||
}
|
||||
None => containers.iter().collect(),
|
||||
};
|
||||
|
||||
let mut report = RecoveryReport {
|
||||
total: containers.len(),
|
||||
recovered: 0,
|
||||
@@ -386,6 +408,15 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
record.name, record.image
|
||||
);
|
||||
|
||||
// Recovery counts against systemd's start timeout; a heavy node
|
||||
// legitimately needs several minutes for dozens of containers. Push
|
||||
// the deadline out ahead of each container so systemd only kills us
|
||||
// if we stop making progress (360s covers one full attempt chain).
|
||||
let _ = sd_notify::notify(
|
||||
false,
|
||||
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
|
||||
);
|
||||
|
||||
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
@@ -427,6 +458,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
attempt + 1,
|
||||
stderr.trim()
|
||||
);
|
||||
// The container is gone (raced past the pre-filter, or the
|
||||
// filter query failed) — retrying can never succeed.
|
||||
if stderr.contains("no such container") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -448,6 +484,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
report
|
||||
}
|
||||
|
||||
/// All container names podman knows about (running or not). `None` if the
|
||||
/// query fails — callers fail open and attempt every snapshot entry.
|
||||
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
|
||||
let output = podman_output(
|
||||
&["ps", "-a", "--format", "{{.Names}}"],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RecoveryReport {
|
||||
pub total: usize,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! whitelists `install` into `/etc/fips/`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -17,47 +18,145 @@ use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Write the FIPS daemon config based on the local npub and default
|
||||
/// transports. Overwrites any existing file — callers are expected to
|
||||
/// Header prepended to the generated YAML. serde doesn't emit comments, so
|
||||
/// this is concatenated onto the serialised body.
|
||||
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n";
|
||||
|
||||
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
|
||||
///
|
||||
/// This was previously built by `format!`-ing a string literal. Upstream's
|
||||
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
|
||||
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
|
||||
/// off the mesh. Serialising from typed structs lets the compiler and the
|
||||
/// tests below catch drift, instead of a node discovering it at boot after an
|
||||
/// upgrade.
|
||||
///
|
||||
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct FipsConfig {
|
||||
pub node: NodeSection,
|
||||
pub tun: TunSection,
|
||||
pub dns: DnsSection,
|
||||
pub transports: TransportsSection,
|
||||
/// Static peers. Always empty: archipelago feeds peers dynamically via the
|
||||
/// seed-anchors apply loop and federation-invite hooks.
|
||||
pub peers: Vec<PeerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct IdentitySection {
|
||||
/// With `persistent: true` the daemon reuses the key file at
|
||||
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
|
||||
/// ephemeral identity on every start.
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
|
||||
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
|
||||
///
|
||||
/// We enable it so co-located nodes peer directly instead of depending on the
|
||||
/// public anchor being reachable — an anchor blackhole on one network segment
|
||||
/// otherwise islands a node completely.
|
||||
///
|
||||
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
|
||||
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
|
||||
/// working on its own when a node upgrades, with no second config migration.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct LanDiscoverySection {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TunSection {
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub mtu: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DnsSection {
|
||||
pub enabled: bool,
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
|
||||
/// and networks that block outbound UDP can still bootstrap over TCP.
|
||||
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
|
||||
/// fallback handles that layer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportsSection {
|
||||
pub udp: TransportBind,
|
||||
pub tcp: TransportBind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportBind {
|
||||
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
|
||||
/// typed so the shape is checked if static peering is ever needed.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PeerEntry {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
impl Default for FipsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
name: "fips0".to_string(),
|
||||
mtu: 1280,
|
||||
},
|
||||
dns: DnsSection {
|
||||
enabled: true,
|
||||
bind_addr: "127.0.0.1".to_string(),
|
||||
},
|
||||
transports: TransportsSection {
|
||||
udp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
|
||||
},
|
||||
tcp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
|
||||
},
|
||||
},
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the FIPS daemon config. Overwrites any existing file — callers
|
||||
/// re-run this whenever the key or daemon version changes.
|
||||
///
|
||||
/// Schema is intentionally minimal: node identity comes from the key
|
||||
/// file on disk (the daemon handles it), transports enable UDP + TCP
|
||||
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
|
||||
/// Static peer list is empty — archipelago feeds peers dynamically via
|
||||
/// the seed-anchors apply loop and federation-invite hooks.
|
||||
/// Node identity comes from the key file on disk; the static peer list stays
|
||||
/// empty because peers are fed dynamically at runtime.
|
||||
pub fn render_config_yaml() -> String {
|
||||
// Schema matches upstream jmcorgan/fips as of 2026-04. With
|
||||
// `node.identity.persistent: true` the daemon reuses the key file at
|
||||
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
|
||||
// rather than `enabled: true / port: N`. Both UDP and TCP are
|
||||
// enabled by default because the public anchor (fips.v0l.io)
|
||||
// currently answers on TCP/8443 only, and networks that block UDP
|
||||
// outbound can still bootstrap via TCP. Upstream fips no longer
|
||||
// has a `tor:` transport variant — archipelago's own Tor fallback
|
||||
// handles that layer.
|
||||
format!(
|
||||
"# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n\
|
||||
node:\n \
|
||||
identity:\n \
|
||||
persistent: true\n\
|
||||
tun:\n \
|
||||
enabled: true\n \
|
||||
name: fips0\n \
|
||||
mtu: 1280\n\
|
||||
dns:\n \
|
||||
enabled: true\n \
|
||||
bind_addr: \"127.0.0.1\"\n\
|
||||
transports:\n \
|
||||
udp:\n \
|
||||
bind_addr: \"0.0.0.0:{udp}\"\n \
|
||||
tcp:\n \
|
||||
bind_addr: \"0.0.0.0:{tcp}\"\n\
|
||||
peers: []\n",
|
||||
udp = DEFAULT_UDP_PORT,
|
||||
tcp = DEFAULT_TCP_PORT,
|
||||
)
|
||||
let body = serde_yaml::to_string(&FipsConfig::default())
|
||||
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
|
||||
format!("{CONFIG_HEADER}{body}")
|
||||
}
|
||||
|
||||
/// Install the local FIPS key + rendered config into `/etc/fips/`.
|
||||
@@ -205,6 +304,60 @@ mod tests {
|
||||
assert!(!yaml.contains("tor:"));
|
||||
}
|
||||
|
||||
/// Exact-output snapshot. Upstream's config structs are
|
||||
/// `deny_unknown_fields`, so an accidental key rename/addition means the
|
||||
/// daemon won't start. Pinning the full rendering makes any such change
|
||||
/// fail here — where it's cheap — instead of on a node after an upgrade.
|
||||
/// If this fails, re-verify against the upstream schema before updating it.
|
||||
#[test]
|
||||
fn test_rendered_yaml_exact_snapshot() {
|
||||
let expected = "\
|
||||
# Generated by archipelago — do not edit by hand.
|
||||
# Regenerated on every key change and daemon upgrade.
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
lan:
|
||||
enabled: true
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: 127.0.0.1
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: 0.0.0.0:8668
|
||||
tcp:
|
||||
bind_addr: 0.0.0.0:8443
|
||||
peers: []
|
||||
";
|
||||
assert_eq!(render_config_yaml(), expected);
|
||||
}
|
||||
|
||||
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
|
||||
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
|
||||
/// would silently leave LAN discovery off rather than erroring.
|
||||
#[test]
|
||||
fn test_lan_discovery_enabled_at_upstream_path() {
|
||||
let yaml = render_config_yaml();
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
|
||||
assert_eq!(
|
||||
parsed["node"]["discovery"]["lan"]["enabled"],
|
||||
serde_yaml::Value::Bool(true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rendering is deterministic: the startup drift check in server.rs compares
|
||||
/// the freshly rendered config against what's on disk, so any instability
|
||||
/// here would cause an endless reinstall+restart loop of the daemon.
|
||||
#[test]
|
||||
fn test_render_is_deterministic() {
|
||||
assert_eq!(render_config_yaml(), render_config_yaml());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_refuses_when_key_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -98,6 +98,40 @@ async fn main() -> Result<()> {
|
||||
return ceremony::run();
|
||||
}
|
||||
|
||||
// Plain CLI flags must never boot the daemon (a stray `--version` used to
|
||||
// start a second instance next to the systemd one). Handled before any
|
||||
// tracing/state init so stdout stays clean.
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("--version") | Some("-V") => {
|
||||
println!(
|
||||
"archipelago {}-{}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("GIT_HASH").unwrap_or("dev")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Some("--help") | Some("-h") => {
|
||||
println!("Archipelago Bitcoin Node OS");
|
||||
println!();
|
||||
println!("Usage: archipelago [COMMAND]");
|
||||
println!();
|
||||
println!("Running with no arguments starts the node daemon.");
|
||||
println!();
|
||||
println!("Commands:");
|
||||
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -V, --version Print version and exit");
|
||||
println!(" -h, --help Print this help and exit");
|
||||
return Ok(());
|
||||
}
|
||||
Some(other) if other.starts_with('-') => {
|
||||
eprintln!("archipelago: unknown option '{other}' (see --help)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill
|
||||
|
||||
Written for a fresh session that will **cut the OTA release and build the ISO**.
|
||||
Everything below is already committed and pushed to `gitea-ai/main`. Last release
|
||||
was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this release carries (3 commits on top of v1.7.105-alpha)
|
||||
|
||||
| Commit | What | User-visible? |
|
||||
|---|---|---|
|
||||
| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes |
|
||||
| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) |
|
||||
| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly |
|
||||
|
||||
### `9e3ac9ba` — mobile transport pill
|
||||
`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header
|
||||
carries the name on mobile), and the transport pill was nested inside it — so it
|
||||
vanished below 768px. Added a separate `md:hidden` pill next to the peer icon.
|
||||
Frontend was rebuilt and the class verified present in the emitted bundle.
|
||||
|
||||
Caveats worth knowing (pre-existing, not introduced here):
|
||||
- On this code path the backend only ever emits `fips` or `tor`, so the `mesh`
|
||||
and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead.
|
||||
- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a
|
||||
hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie.
|
||||
The peer-files pill does not.
|
||||
|
||||
### `3ab7fb52` — full error chain in logs
|
||||
`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every
|
||||
peer-files failure read exactly `RPC error on content.browse-peer: Failed to
|
||||
connect to peer` with the real cause discarded. Now `{:#}`. The client-facing
|
||||
message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so
|
||||
no internal detail leaks. **This fix applies to every RPC method, not just
|
||||
browse-peer.**
|
||||
|
||||
### `5fd0d6c3` — typed FIPS config + mDNS
|
||||
`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal.
|
||||
Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key
|
||||
does not degrade — **the daemon refuses to start and the node leaves the mesh**.
|
||||
Now a typed serde struct tree, verified field-by-field against jmcorgan/fips
|
||||
**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and
|
||||
the pre-existing schema test. All pass.
|
||||
|
||||
Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0)
|
||||
so co-located nodes peer directly instead of depending on the public anchor.
|
||||
|
||||
> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup
|
||||
> drift check at `server.rs:864` compares the freshly rendered config against
|
||||
> what's on disk. The render differs now, so it reinstalls the config and
|
||||
> restarts the FIPS daemon **once**. This is the intended self-healing path and
|
||||
> settles immediately. Do not mistake it for a regression.
|
||||
|
||||
Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it
|
||||
harmlessly (verified against the v0.3.0 source). It self-activates on upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled
|
||||
|
||||
Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1**
|
||||
(2026-07-19). Verified before touching anything:
|
||||
|
||||
- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day.
|
||||
- **Config forward-compatible** — every key we emit exists in 0.4.1.
|
||||
- **Asset names match** what `fips/update.rs` expects (`fips_<ver>_<arch>.deb` +
|
||||
`checksums-linux.txt`), so the in-product updater should work.
|
||||
|
||||
### Upgraded so far (2 of N)
|
||||
| Node | Before | After | Result |
|
||||
|---|---|---|---|
|
||||
| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` |
|
||||
| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) |
|
||||
|
||||
The OptiPlex was still running the **old string-rendered config** and 0.4.1
|
||||
accepted it — empirical confirmation of the compat analysis, not just desk work.
|
||||
|
||||
### Upgrade recipe (nodes cannot reach GitHub — sideload)
|
||||
```bash
|
||||
# 1. On a host with GitHub access:
|
||||
curl -sL -o fips_0.4.1_amd64.deb \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb
|
||||
curl -sL -o checksums-linux.txt \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt
|
||||
sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt
|
||||
# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded
|
||||
|
||||
# 2. Sideload:
|
||||
scp fips_0.4.1_amd64.deb archipelago@<node>:/tmp/
|
||||
|
||||
# 3. On the node — the same command update.rs uses:
|
||||
sudo -n systemd-run --collect --wait --quiet --pipe -- \
|
||||
env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \
|
||||
/tmp/fips_0.4.1_amd64.deb
|
||||
|
||||
# 4. Restart the ACTIVE unit — it is archipelago-fips.service,
|
||||
# NOT fips.service (which is inactive on these nodes):
|
||||
sudo -n systemctl restart archipelago-fips.service
|
||||
|
||||
# 5. Verify:
|
||||
fipsctl --version
|
||||
sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected
|
||||
sudo -n fipsctl show tree # expect is_root: false, depth > 0
|
||||
```
|
||||
|
||||
### ISO implication (important)
|
||||
`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from
|
||||
**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64
|
||||
only). So a freshly built ISO will pick up whatever main is that day — probably
|
||||
≥0.4.1, but it is not deterministic. Pinning is an open item in
|
||||
`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1
|
||||
before building the release ISO** so the shipped version is knowable.
|
||||
|
||||
---
|
||||
|
||||
## 3. The original bug — peer cloud files not loading
|
||||
|
||||
**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit
|
||||
because it would be easy to read this as closed.
|
||||
|
||||
What is established:
|
||||
- FIPS was fully down on the thinkpad: `fipsctl show peers` → `[]`, `show links`
|
||||
→ `[]`, `show tree` → `is_root: true, depth 0`. An island.
|
||||
- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the
|
||||
public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on
|
||||
ICMP, 443/8443/8668 all time out. `show transports` showed
|
||||
`packets_sent: 760, packets_recv: 0` on both UDP and TCP.
|
||||
- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock
|
||||
Tailscale anti-spoof DROPs).
|
||||
- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's
|
||||
WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh` → `FAILED`).
|
||||
- With no FIPS tree, everything falls back to Tor. Every peer in
|
||||
`federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`.
|
||||
- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×,
|
||||
HTTP 200 in 4.1–8.5s — well inside the 30s budget at `content.rs:349`.
|
||||
|
||||
What is **not** established: why three specific `content.browse-peer` calls
|
||||
failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never
|
||||
reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic
|
||||
is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and
|
||||
the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is
|
||||
cold-circuit timeouts on first fetch after idle — **a guess, not a finding.**
|
||||
`3ab7fb52` means the next occurrence will log the actual cause.
|
||||
|
||||
### Corrections to earlier claims in this session
|
||||
- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the
|
||||
`ip:port` in `fipsctl connect` is only an underlay endpoint hint.
|
||||
- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy
|
||||
(`delivery_ratio` 1.0 both directions, bloom filter syncing). The
|
||||
`bytes_recv: 0` link counters are simply uninstrumented in 0.3.0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open items — decisions NOT taken
|
||||
|
||||
1. **Second FIPS anchor (user asked for this; not built).** Needs a host running
|
||||
FIPS that is reachable from the restricted WiFi. Candidate found: OVH
|
||||
**`146.59.87.168`** — pings fine from the thinkpad and general egress works
|
||||
(github 200), while the upstream anchor fails even ICMP there. But it does not
|
||||
run FIPS yet, so this means **installing FIPS on the box that hosts Gitea** —
|
||||
a production change, deliberately not made unprompted. Code side is easy after:
|
||||
`fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list
|
||||
(`default_public_anchor()` → `default_public_anchors() -> Vec<SeedAnchor>`).
|
||||
2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228`
|
||||
(`100.64.204.114`) has been **offline ~20h** and could not be included.
|
||||
3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet,
|
||||
so mDNS is not actually live anywhere. That is what this OTA is for.
|
||||
4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so
|
||||
mDNS may not rescue that particular node even after the OTA. It will help
|
||||
co-located nodes on sane networks.
|
||||
5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO.
|
||||
|
||||
---
|
||||
|
||||
## 5. Release ritual (from prior sessions — follow exactly)
|
||||
|
||||
Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`,
|
||||
`release-manifest.json`, `releases/manifest.json` modified, and an untracked
|
||||
`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another
|
||||
agent may share this tree; never `git add -A`.
|
||||
|
||||
```bash
|
||||
V=1.7.106-alpha
|
||||
|
||||
# Frontend build — MUST verify dist actually changed (build can silently no-op)
|
||||
cd neode-ui && npm run build # → web/dist/neode-ui/
|
||||
grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity
|
||||
|
||||
# Backend
|
||||
cd core && cargo build --release -p archipelago
|
||||
# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache
|
||||
# corruption — rebuild with CARGO_INCREMENTAL=0
|
||||
|
||||
# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s
|
||||
tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui .
|
||||
tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html
|
||||
# Exclude the ~17MB companion APK from tarballs.
|
||||
|
||||
# Ship
|
||||
scripts/create-release.sh $V
|
||||
scripts/publish-release-assets.sh $V gitea-vps2
|
||||
git push origin main && git push origin --tags # tag or the Releases page stays empty
|
||||
git push gitea-ai main # main is protected; use the `ai` account
|
||||
|
||||
# Verify the live manifest
|
||||
curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
|
||||
```
|
||||
|
||||
Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is
|
||||
done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first
|
||||
(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with
|
||||
user benefit.
|
||||
|
||||
### ISO
|
||||
```bash
|
||||
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh
|
||||
```
|
||||
ISO builds are **always unbundled** — the default env silently builds the wrong
|
||||
full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output
|
||||
filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is
|
||||
`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO.
|
||||
|
||||
---
|
||||
|
||||
## 6. Node access quick reference
|
||||
|
||||
- **thinkpad (`.116`) is the local machine** — do not SSH to it; read
|
||||
`journalctl -u archipelago` and `/var/lib/archipelago/**` directly.
|
||||
- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user
|
||||
`archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale.
|
||||
- `.228` = `archipelago-2` / `100.64.204.114` — **offline as of 2026-07-20**, and
|
||||
it is in real use; don't touch uninvited.
|
||||
- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex.
|
||||
- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores.
|
||||
- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660).
|
||||
- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74).
|
||||
@@ -254,17 +254,29 @@ container_pull() {
|
||||
echo "📦 Step 1: Building root filesystem..."
|
||||
|
||||
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
|
||||
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
|
||||
# The cached rootfs must be invalidated when its recipe changes: a stale
|
||||
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
|
||||
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
|
||||
# were added to the Dockerfile below — the cache condition never looked at
|
||||
# the recipe. Hash the rootfs-defining region of this script; any edit to it
|
||||
# forces a rebuild. `--rebuild` still forces one unconditionally.
|
||||
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
|
||||
echo " Using Docker to create Debian root filesystem..."
|
||||
|
||||
# Create a Dockerfile for building the rootfs
|
||||
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb from upstream main ─────────
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb at a pinned tag ────────────
|
||||
#
|
||||
# FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing
|
||||
# protocol archipelago uses as its preferred non-Tor transport. We track
|
||||
# upstream main per project decision (2026-04) — v0.2.0 isn't stable yet.
|
||||
# protocol archipelago uses as its preferred non-Tor transport.
|
||||
# Pinned so the shipped version is knowable: an unpinned --depth 1 clone of
|
||||
# main made every ISO carry whatever upstream happened to be that day.
|
||||
# v0.4.1 is the version fips/config.rs renders its typed config against and
|
||||
# the one validated in the field. Bump the two together.
|
||||
# The .deb is rebuilt every ISO build; Docker layer caching keeps the
|
||||
# incremental cost low. Failure here fails the ISO build on purpose:
|
||||
# we don't want to ship an ISO that silently skips FIPS.
|
||||
@@ -282,7 +294,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
clang libclang-dev libnftnl-dev libmnl-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install --locked cargo-deb
|
||||
RUN git clone --depth 1 https://github.com/jmcorgan/fips.git /src/fips
|
||||
ARG FIPS_VERSION=v0.4.1
|
||||
RUN git clone --depth 1 --branch "\$FIPS_VERSION" \\
|
||||
https://github.com/jmcorgan/fips.git /src/fips
|
||||
WORKDIR /src/fips
|
||||
# fips-gateway is gated behind the `gateway` Cargo feature (depends on
|
||||
# `rustables`). Without the feature, cargo doesn't build it, and
|
||||
@@ -694,6 +708,7 @@ SYSTEMDSERVICE
|
||||
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
|
||||
$CONTAINER_CMD rm archipelago-rootfs-tmp
|
||||
|
||||
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
|
||||
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
else
|
||||
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
@@ -1218,6 +1233,23 @@ else
|
||||
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
|
||||
fi
|
||||
|
||||
# A missing nvpn/nostr-rs-relay used to be a warning, and the resulting ISO
|
||||
# shipped units that crash-looped (or silently lacked VPN signaling) on every
|
||||
# install. Refuse to produce that ISO unless explicitly overridden.
|
||||
MISSING_VPN_BINARIES=""
|
||||
[ -f "$ARCH_DIR/bin/nvpn" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nvpn"
|
||||
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
|
||||
if [ -n "$MISSING_VPN_BINARIES" ]; then
|
||||
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
|
||||
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
|
||||
else
|
||||
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
|
||||
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
|
||||
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy WireGuard helper script
|
||||
if [ -f "$WORK_DIR/archipelago-wg" ]; then
|
||||
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
|
||||
@@ -1658,17 +1690,17 @@ LOG="/var/log/archipelago-tor.log"
|
||||
|
||||
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
|
||||
|
||||
# Write services.json for the backend to read
|
||||
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
|
||||
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
|
||||
# hidden services the backend added after app installs. Only the node's own
|
||||
# service is pre-baked — apps get their hidden service created on install
|
||||
# (auto_add_tor_service / tor.create-service), never pre-created for apps
|
||||
# that may never be installed (issue #79).
|
||||
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
|
||||
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
|
||||
{
|
||||
"services": [
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true},
|
||||
{"name": "bitcoin", "local_port": 8333, "enabled": true},
|
||||
{"name": "electrumx", "local_port": 50001, "enabled": true},
|
||||
{"name": "lnd", "local_port": 9735, "enabled": true},
|
||||
{"name": "btcpay", "local_port": 23000, "enabled": true},
|
||||
{"name": "mempool", "local_port": 4080, "enabled": true},
|
||||
{"name": "fedimint", "local_port": 8175, "enabled": true}
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true}
|
||||
]
|
||||
}
|
||||
TORJSON
|
||||
@@ -1688,33 +1720,16 @@ SocksPolicy reject *
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
|
||||
HiddenServicePort 80 127.0.0.1:80
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
|
||||
HiddenServicePort 8333 127.0.0.1:8333
|
||||
HiddenServicePort 8332 127.0.0.1:8332
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
|
||||
HiddenServicePort 50001 127.0.0.1:50001
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_lnd
|
||||
HiddenServicePort 9735 127.0.0.1:9735
|
||||
HiddenServicePort 8080 127.0.0.1:8080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
|
||||
HiddenServicePort 23000 127.0.0.1:23000
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_mempool
|
||||
HiddenServicePort 4080 127.0.0.1:4080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
|
||||
HiddenServicePort 8175 127.0.0.1:8175
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_relay
|
||||
HiddenServicePort 7777 127.0.0.1:7777
|
||||
TORRC
|
||||
else
|
||||
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
|
||||
fi
|
||||
|
||||
# Create hidden service dirs with correct ownership and permissions (700, not 750)
|
||||
# Tor refuses to start if permissions are too permissive
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
mkdir -p "$TOR_DIR/hidden_service_$svc"
|
||||
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
|
||||
chmod 700 "$TOR_DIR/hidden_service_$svc"
|
||||
@@ -1759,7 +1774,7 @@ done
|
||||
# Sync hostnames to backend-readable directory
|
||||
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
|
||||
mkdir -p "$HOSTNAMES_DIR"
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
|
||||
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
|
||||
echo "$(date): Synced hostname: $svc" >> "$LOG"
|
||||
@@ -3345,6 +3360,11 @@ echo ""
|
||||
echo "=== Done ==="
|
||||
DIAGSCRIPT
|
||||
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
|
||||
# v1.7.104 shipped installs where this script was missing while its unit was
|
||||
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
|
||||
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
|
||||
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
|
||||
fi
|
||||
|
||||
# Systemd oneshot service for first-boot diagnostics
|
||||
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
@@ -3352,6 +3372,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
Description=Archipelago First Boot Diagnostics
|
||||
After=multi-user.target archipelago.service nginx.service
|
||||
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
|
||||
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
|
||||
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=nostr-vpn.service
|
||||
# An ISO built without the relay binary (registry unreachable at build time)
|
||||
# must not crash-loop every 3s forever — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nostr-rs-relay
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
# An ISO built without the nvpn binary (registry unreachable at build time)
|
||||
# must not restart-loop — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nvpn
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.104-alpha",
|
||||
"version": "1.7.106-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.104-alpha",
|
||||
"version": "1.7.106-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.104-alpha",
|
||||
"version": "1.7.106-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
+15
-4
@@ -424,10 +424,14 @@ onMounted(async () => {
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
const splashCandidate = !seenIntro
|
||||
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
// Root boots always ask the backend — even when this browser thinks it has
|
||||
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
|
||||
// are per-origin browser state: after a reinstall (or another node coming
|
||||
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
|
||||
// a fresh install's intro / misroute it to login.
|
||||
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
|
||||
|
||||
if (splashCandidate && onboardingComplete !== true) {
|
||||
if (splashCandidate) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
// Bound the pre-splash status check: its retry ladder can spend ~30s
|
||||
@@ -437,10 +441,17 @@ onMounted(async () => {
|
||||
// the splash play (a fresh install IS the slow-backend case; onboarded
|
||||
// nodes answer in milliseconds, so their suppression path is intact).
|
||||
// handleSplashComplete re-checks with full retries after the intro.
|
||||
onboardingComplete = await Promise.race([
|
||||
const live = await Promise.race([
|
||||
checkOnboardingStatus(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
|
||||
])
|
||||
if (live !== null) onboardingComplete = live
|
||||
if (live === false && seenIntro) {
|
||||
// Backend-confirmed fresh node behind a browser with a stale flag —
|
||||
// drop it so this boot (and every later one) plays the intro.
|
||||
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
|
||||
seenIntro = false
|
||||
}
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
|
||||
@@ -161,6 +161,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||
<button
|
||||
v-if="wgError && !wgLoading"
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
|
||||
@click="retryWgPeer"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||
the config as a file WireGuard can import. -->
|
||||
@@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||
// it — and the WG steps mid-pairing race the phone's changing network (the
|
||||
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
|
||||
// companions lives in the NESMenu instead.
|
||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
||||
|
||||
onMounted(() => {
|
||||
if (IN_COMPANION_APP) return
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
@@ -471,6 +487,19 @@ function backFromPair() {
|
||||
}
|
||||
}
|
||||
|
||||
// Network-class failures: the node itself was unreachable (as opposed to the
|
||||
// backend answering with an RPC error). Includes the client's own timeout.
|
||||
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
|
||||
|
||||
// Auto-retry ladder for network-class failures. On a first install this step
|
||||
// is often reached while the backend is still settling (services starting,
|
||||
// backend restarting during container orchestration) — a single failed fetch
|
||||
// left a permanently blank QR unless the user spotted the retry button.
|
||||
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
|
||||
|
||||
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||
// looked up first so reopening the modal never duplicates it.
|
||||
@@ -478,30 +507,71 @@ async function loadWgPeer() {
|
||||
if (wgQrDataUrl.value || wgLoading.value) return
|
||||
wgLoading.value = true
|
||||
wgError.value = ''
|
||||
try {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => ({ peers: [] as { name: string }[] }))
|
||||
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
|
||||
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
|
||||
params: { name: WG_PEER_NAME },
|
||||
})
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
|
||||
} finally {
|
||||
wgLoading.value = false
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await provisionWgPeer()
|
||||
break
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : ''
|
||||
const isNetworkErr = WG_NETWORK_ERR.test(raw)
|
||||
const retryDelay = WG_RETRY_DELAYS_MS[attempt]
|
||||
if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) {
|
||||
await sleep(retryDelay)
|
||||
if (stillOnWgStep()) continue
|
||||
}
|
||||
// fetch()'s raw "Failed to fetch" means the node itself was unreachable —
|
||||
// after the retry ladder that's usually the phone's network mid-change
|
||||
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
|
||||
// Say so, and leave a Retry path instead of a dead end.
|
||||
wgError.value = isNetworkErr
|
||||
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
|
||||
: raw || 'Failed to generate the tunnel config'
|
||||
break
|
||||
}
|
||||
}
|
||||
wgLoading.value = false
|
||||
}
|
||||
|
||||
async function provisionWgPeer() {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => null)
|
||||
// list-peers unreachable → don't guess "doesn't exist": create-peer on an
|
||||
// existing name would fail. Try create first, fall back to peer-config.
|
||||
const exists = listed === null
|
||||
? null
|
||||
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
let res: { config: string; peer_ip: string }
|
||||
if (exists === true) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
try {
|
||||
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
|
||||
} catch (e) {
|
||||
// Peer already provisioned on a previous visit (list failed or raced).
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
if (/exist|duplicate/i.test(msg)) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function retryWgPeer() {
|
||||
wgError.value = ''
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
// Same-device path: hand the config to the WireGuard app as an importable
|
||||
|
||||
@@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => {
|
||||
replayRequested: true,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/login',
|
||||
fromBoot: true,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('fresh node on a deep route without boot handoff stays suppressed', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/onboarding/seed',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('boot dev mode never root-boots into the intro', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
devMode: 'boot',
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput {
|
||||
|
||||
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||
if (input.replayRequested) return true
|
||||
|
||||
const isDirectRoute = input.routePath !== '/'
|
||||
// A node the backend CONFIRMS has never completed onboarding always gets
|
||||
// the full intro on a root boot. `seenIntro` is per-origin browser state —
|
||||
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
|
||||
// previous node's flag at the same origin, which silently muted the intro
|
||||
// on genuinely fresh installs.
|
||||
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
|
||||
return true
|
||||
}
|
||||
if (input.seenIntro) return false
|
||||
if (input.onboardingComplete === true) return false
|
||||
|
||||
const isDirectRoute = input.routePath !== '/'
|
||||
if (input.fromBoot) return true
|
||||
if (input.devMode === 'boot') return false
|
||||
return !isDirectRoute
|
||||
|
||||
@@ -691,20 +691,24 @@ video.bg-layer {
|
||||
why the kiosk login/onboarding background still went black. Keep 2D
|
||||
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
|
||||
glitch overlays. */
|
||||
:global(html.kiosk-mode) .bg-perspective-container,
|
||||
:global(html.kiosk-mode) .perspective-container {
|
||||
/* The full selector must live inside :global() — with `:global(html.kiosk-mode)
|
||||
.bg-layer` the SFC compiler drops the descendant part, emitting bare
|
||||
`html.kiosk-mode { display: none !important }` rules that blank the whole
|
||||
document on kiosk (the v1.7.104 white-screen). */
|
||||
:global(html.kiosk-mode .bg-perspective-container),
|
||||
:global(html.kiosk-mode .perspective-container) {
|
||||
perspective: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .bg-layer,
|
||||
:global(html.kiosk-mode) .view-wrapper {
|
||||
:global(html.kiosk-mode .bg-layer),
|
||||
:global(html.kiosk-mode .view-wrapper) {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
will-change: auto !important;
|
||||
filter: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .login-glitch-layer,
|
||||
:global(html.kiosk-mode) .login-glitch-scan {
|
||||
:global(html.kiosk-mode .login-glitch-layer),
|
||||
:global(html.kiosk-mode .login-glitch-scan) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
|
||||
<p v-else class="text-sm text-white/50">Peer files</p>
|
||||
</div>
|
||||
<!-- Mobile: the title block above is hidden (the global header carries the
|
||||
peer name), so the transport pill would vanish with it. Render it on
|
||||
its own here so mobile also sees whether this peer is FIPS or Tor. -->
|
||||
<span
|
||||
v-if="transportPill"
|
||||
:class="transportPill.cls"
|
||||
:title="transportPill.title"
|
||||
class="md:hidden text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
>{{ transportPill.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -362,6 +362,36 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.106-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.106-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.</p>
|
||||
<p>On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.</p>
|
||||
<p>Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.</p>
|
||||
<p>When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.</p>
|
||||
<p>Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.105-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.105-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.</p>
|
||||
<p>Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.</p>
|
||||
<p>Fixed the white screen some laptop displays showed right after the intro on v1.7.104.</p>
|
||||
<p>The companion phone app no longer suggests installing the companion app from inside itself.</p>
|
||||
<p>The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.</p>
|
||||
<p>Running archipelago --version or --help on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.</p>
|
||||
<p>Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.104-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+19
-18
@@ -1,30 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.",
|
||||
"If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.",
|
||||
"The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.",
|
||||
"While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress."
|
||||
"Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.",
|
||||
"On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.",
|
||||
"Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.",
|
||||
"When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only \"Failed to connect to peer\" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.",
|
||||
"Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
|
||||
"current_version": "1.7.106-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
|
||||
"size_bytes": 49976648
|
||||
"new_version": "1.7.106-alpha",
|
||||
"sha256": "1b08fe3fdd96bdc6600f811a6c273675ed87529ee1a4a7cb35d0b94ce10912d9",
|
||||
"size_bytes": 50392736
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
|
||||
"size_bytes": 174592628
|
||||
"current_version": "1.7.106-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago-frontend-1.7.106-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.106-alpha.tar.gz",
|
||||
"new_version": "1.7.106-alpha",
|
||||
"sha256": "c37dc72ee3b458b169911862da8ca3ec30454e63e0f3c7492e38889a9fbde89f",
|
||||
"size_bytes": 174594058
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-19",
|
||||
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "2059c74b748fb6e60c8e29b38774be0fef6f85e7cc7992feff03de128ab269d74345b2d0e6cb9d78d4b447abbecdbe8d0d9b60e46ffffbc0e75de4d52d290f05",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.104-alpha"
|
||||
"version": "1.7.106-alpha"
|
||||
}
|
||||
|
||||
+19
-18
@@ -1,30 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.",
|
||||
"If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.",
|
||||
"The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.",
|
||||
"While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress."
|
||||
"Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.",
|
||||
"On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.",
|
||||
"Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.",
|
||||
"When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only \"Failed to connect to peer\" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.",
|
||||
"Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
|
||||
"current_version": "1.7.106-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
|
||||
"size_bytes": 49976648
|
||||
"new_version": "1.7.106-alpha",
|
||||
"sha256": "1b08fe3fdd96bdc6600f811a6c273675ed87529ee1a4a7cb35d0b94ce10912d9",
|
||||
"size_bytes": 50392736
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
|
||||
"size_bytes": 174592628
|
||||
"current_version": "1.7.106-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago-frontend-1.7.106-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.106-alpha.tar.gz",
|
||||
"new_version": "1.7.106-alpha",
|
||||
"sha256": "c37dc72ee3b458b169911862da8ca3ec30454e63e0f3c7492e38889a9fbde89f",
|
||||
"size_bytes": 174594058
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-19",
|
||||
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "2059c74b748fb6e60c8e29b38774be0fef6f85e7cc7992feff03de128ab269d74345b2d0e6cb9d78d4b447abbecdbe8d0d9b60e46ffffbc0e75de4d52d290f05",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.104-alpha"
|
||||
"version": "1.7.106-alpha"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,23 @@ for f in /usr/local/bin/archipelago \
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
|
||||
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
|
||||
# crash-loop forever) and archipelago-diag.service without its script
|
||||
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
|
||||
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
|
||||
[ -f "$unit" ] || continue
|
||||
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
|
||||
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
|
||||
case "$exec_bin" in
|
||||
/*) if [ -e "$exec_bin" ]; then
|
||||
pass "Unit payload exists: $(basename "$unit") → $exec_bin"
|
||||
else
|
||||
fail "Unit payload missing" "$(basename "$unit") → $exec_bin"
|
||||
fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1.2 — Critical services active
|
||||
for svc in archipelago nginx; do
|
||||
if systemctl is-active "$svc" >/dev/null 2>&1; then
|
||||
|
||||
Reference in New Issue
Block a user