Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 edcce5a308 feat(nostr-vpn): package paid-exit seller + web control panel as manifest apps
Phase 1 of docs/nostr-vpn-integration-plan.md's Phase 0->4 plan (seller-side
rootless feasibility already confirmed there). Two apps, one image:

- apps/nostr-vpn: the daemon. Own network namespace (container.network:
  pasta), NET_ADMIN+NET_RAW scoped to that netns, /dev/net/tun, and the
  net.ipv4.ip_forward sysctl via the primitive added in e42bd26. UDP 51822
  (not upstream's default 51820, which collides with archipelago-wg on
  fleet nodes per the Phase 0 log). Seller mode stays off until an operator
  explicitly enables it (paid_exit.enabled defaults to false upstream).
- apps/nostr-vpn-web: the control panel, gated behind 127.0.0.1:38080,
  talking to the daemon only through the shared /data volume (state-file
  status + shelling out to the nvpn CLI) -- no network link between the
  two containers, matching upstream's own umbrel/docker-compose.yml.
- docker/nostr-vpn: upstream's umbrel/Dockerfile, unchanged except for how
  the pinned commit arrives (shallow git fetch of a verified SHA, since
  codeload.github.com archive tarballs 404 from this environment and
  GitHub won't fetch an arbitrary SHA directly). Entrypoint seeds a minimal
  config.toml with the chosen listen_port on first boot only -- every
  AppConfig field is `serde(default = ...)`, confirmed by reading
  nostr-vpn-core directly, so this merges with nvpn's own identity/wallet
  bootstrap instead of needing a generated_secrets entry or full config
  template, and never touches a config that already exists.

Both volumes point at /var/lib/archipelago/nostr-vpn, adopting state from
the old root-mode install. Build and the seed-config path were verified
against the real `nvpn daemon` binary, not just read -- see the plan doc's
Phase 1 log for what that caught (a fabricated commit SHA, the codeload
404, wrong default branch name, and confirming identity/wallet persistence
actually survives container recreation).

Not done here, flagged in the plan doc instead: removing the old root-mode
path (rpc/vpn.rs, rpc/auth.rs's auto-enable-on-login) touches live
onboarding on every node, not just this app -- needs explicit sign-off.
Also missing: a stop-hook/uninstall-guard manifest primitive (doesn't
exist yet -- LifecycleHooks only has post_install/pre_start) for the
collect-due-on-stop and non-zero-wallet uninstall guard, and registry
mirroring + catalog signing (need credentials this pass doesn't have).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 00:29:51 +00:00
ssmithxandClaude Opus 5.5 e42bd26ec7 feat(manifest): add allow-listed per-netns sysctls primitive
Routing apps (a rootless VPN exit) need packet forwarding in their own
network namespace, but /proc/sys is read-only inside a rootless
container, so it can only be set at create time. Add `app.sysctls`,
allow-listed to net.ipv4.ip_forward / net.ipv6.conf.all.forwarding with
values "0"/"1", and rejected under host networking where it would change
the host. Rendered on all three create paths: podman CLI --sysctl, the
libpod spec `sysctl` map, and Quadlet `Sysctl=`. Absent by default and
not serialized when empty, so existing manifests and units are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:16:53 +00:00
33 changed files with 624 additions and 578 deletions
-6
View File
@@ -2,12 +2,6 @@
## v1.8.22-alpha (2026-09-30)
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
- Fixed companion dashboard builds still referencing a retired image registry.
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
+89
View File
@@ -0,0 +1,89 @@
app:
id: nostr-vpn-web
name: Nostr VPN Control Panel
version: 1.0.0
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Web control panel for the nostr-vpn paid-exit seller (apps/nostr-vpn).
Talks to the daemon only through the shared /data volume (state-file
status + shelling out to the nvpn CLI) -- no network link between the
two containers, mirroring upstream's own umbrel/docker-compose.yml
exactly (read directly, not assumed). Same image as apps/nostr-vpn,
different entrypoint args.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn-web
- --listen
- 0.0.0.0:38080
- --behind-trusted-proxy
- --config
- /data/config/nvpn/config.toml
dependencies:
- app_id: nostr-vpn
resources:
memory_limit: 128Mi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: bridge
ports:
- host: 38080
container: 38080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
# Same volume as apps/nostr-vpn, read-write: the panel's wallet/seller
# actions (wallet send, paid-exit run) shell out to the nvpn CLI
# against this same config.toml and data dir, per
# NVPN_EXTERNAL_DAEMON/NVPN_DAEMON_STATUS_MODE below.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_CLI_PATH=/usr/local/bin/nvpn
- NVPN_DAEMON_STATUS_MODE=state-file
- NVPN_EXTERNAL_DAEMON=true
health_check:
type: http
endpoint: http://127.0.0.1:38080
path: /
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Control Panel
description: nostr-vpn paid-exit status, wallet, and seller settings
type: ui
port: 38080
protocol: http
path: /
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Paid-exit seller status, wallet, and offer controls
- Shares state with apps/nostr-vpn via one data volume, no RPC link
+119
View File
@@ -0,0 +1,119 @@
app:
id: nostr-vpn
name: Nostr VPN (paid exit)
version: 1.0.0
# Pinned commit, not a tag -- upstream has no release tags yet. Re-pin
# deliberately in docker/nostr-vpn/Dockerfile's NVPN_COMMIT build arg; see
# docs/nostr-vpn-integration-plan.md for the Phase 0 feasibility log this
# pin was verified against.
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Sells spare bandwidth as a Nostr-discovered, Cashu-metered paid exit
(github.com/mmalmi/nostr-vpn). Runs rootless in its own network
namespace (pasta) -- NET_ADMIN/NET_RAW are scoped to that netns, never
the host. Seller mode defaults OFF (upstream's own `paid_exit.enabled`
default); turning it on is a separate step (Phase 3 UI, not yet built).
This replaces the old root-mode integration (image-recipe's
nostr-vpn.service running `nvpn daemon` as root, auto-enabled on first
login via rpc/auth.rs) that broke the rootless/no-OS-reliance
invariant. That old path and its RPC TOML-rewriting code
(rpc/vpn.rs::handle_vpn_add_participant) are a separate, higher-risk
removal -- not done here, since it's wired into every node's login
flow today, not just this app.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
network: pasta
# Image has no image-level ENTRYPOINT/CMD (see Dockerfile) -- both this
# app and nostr-vpn-web point the shared seed-config entrypoint at
# different binaries/args.
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn
- daemon
- --config
- /data/config/nvpn/config.toml
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
security:
# NET_ADMIN/NET_RAW: TUN device + the exit forwarding/NAT nvpn installs
# itself inside its own netns (nvpn-exit-forward-in/out, nvpn-exit-masq,
# the MSS clamp) -- confirmed working rootless in Phase 0 testing, with
# no capabilities beyond these two plus the sysctl below. Host iptables
# and routes were confirmed untouched.
capabilities: [NET_ADMIN, NET_RAW]
# false: not verified read-only-root-compatible in Phase 0 testing (the
# working run flags there didn't include --read-only). nvpn's own state
# (config/identity/wallet) lives on the /data volume either way.
readonly_root: false
no_new_privileges: true
network_policy: isolated
# Rootless /proc/sys is read-only, so forwarding can only be set at
# container-create time via this primitive (added for exactly this app --
# see commit e42bd26). nvpn only *reads* ip_forward and writes it when 0,
# so setting it here once at create is enough; nvpn's own cleanup path
# leaves it alone.
sysctls:
net.ipv4.ip_forward: "1"
devices:
- /dev/net/tun
ports:
# Paid-exit buyers dial this directly from the open internet to pay for
# bandwidth -- it's the whole point of the app, not an admin surface,
# and it speaks nvpn's own FIPS UDP wire protocol, not HTTP, so the app
# gate cannot front it. 51822, not upstream's default 51820: that
# collides with archipelago-wg (kernel WireGuard) on fleet nodes --
# found running both side by side in Phase 0 testing.
- host: 51822
container: 51822
protocol: udp
auth: none
auth_rationale: >-
FIPS UDP transport for paid-exit buyers. Anonymous by design (not
HTTP), and the seller is off by default (paid_exit.enabled=false)
until an operator explicitly turns on selling, so exposure here
alone grants no access to anything.
volumes:
# Adopts whatever a node already has under the old root-mode path
# (nostr-vpn.service wrote here too) -- an identity, wallet balance, or
# pending Cashu credit must survive this migration, not reset.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_LISTEN_PORT=51822
health_check:
type: exec
endpoint: nvpn status
interval: 30s
timeout: 10s
retries: 3
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Sell spare bandwidth as a Cashu-metered Nostr paid exit
- Rootless: own network namespace, no host network access
- Seller mode off by default
@@ -103,15 +103,6 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
}
}
/// Missing companion UIs are provisioned here, never by snapshot recovery.
/// A stale running-container snapshot must not resurrect an orphaned UI.
pub fn is_companion_app(app_id: &str) -> bool {
ALL_COMPANIONS
.iter()
.flat_map(|specs| specs.iter())
.any(|spec| spec.image_base == app_id)
}
/// Every companion this build knows how to provision. Kept beside
/// `companions_for` — a new companion must be added to both, or the reaper
/// will not recognise it as one of ours and will leave it running forever.
@@ -978,20 +978,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
let mut first_candidate = None;
for port_str in ports {
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
let Some((public_part, _)) = port_str.split_once("->") else {
let Some(public_part) = port_str.split("->").next() else {
continue;
};
let Some((_, port_part)) = public_part.rsplit_once(':') else {
let Some(port_part) = public_part.split(':').nth(1) else {
continue;
};
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
let host_port = port_part.split('-').next().unwrap_or(port_part);
let Ok(host_port) = host_port.parse::<u16>() else {
continue;
};
if host_port == 0 {
continue;
}
let candidate = format!("http://localhost:{}", host_port);
if first_candidate.is_none() {
first_candidate = Some(candidate.clone());
@@ -1119,29 +1113,6 @@ fn package_launch_candidate(
if let Some(companion) = companion_lan_address(app_id) {
return Some(companion);
}
if app_id == "nginx-proxy-manager" {
// 80/443 serve users' proxy hosts; only container port 81 serves the
// admin UI. Podman's binding order is unstable across recreation.
// Resolve its actual host allocation rather than guessing the first
// HTTP port or hardcoding the default host port 8081.
let admin_ports: Vec<String> = ports
.iter()
.filter(|port| {
port.split_once("->")
.is_some_and(|(_, target)| target == "81/tcp")
})
.cloned()
.collect();
// With published bindings, a missing admin mapping is not evidence
// that some unrelated service on the default host port is this UI.
return extract_lan_address(&admin_ports).or_else(|| {
if ports.is_empty() {
known
} else {
None
}
});
}
if uses_allocated_launch_port(app_id) {
extract_lan_address(ports).or(known)
} else {
@@ -1270,98 +1241,6 @@ mod extract_lan_address_tests {
);
}
#[test]
fn npm_admin_launch_is_independent_of_proxy_binding_order() {
let mappings = [
"10.77.0.2:18081->80/tcp",
"10.77.0.2:18443->443/tcp",
"127.0.0.1:8081->81/tcp",
];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081")
);
}
}
#[test]
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
let ports = vec![
"10.77.0.2:18081->80/tcp".into(),
format!("{binding}:28081->81/tcp"),
];
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:28081")
);
}
let proxies = vec![
"10.77.0.2:18081->80/tcp".into(),
"10.77.0.2:18443->443/tcp".into(),
];
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &proxies, None),
None
);
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&proxies,
Some("http://localhost:8081/".into())
),
None
);
}
#[test]
fn npm_without_port_information_uses_declared_admin_url() {
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&[],
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081/")
);
}
#[test]
fn malformed_published_ports_do_not_become_launch_urls() {
for port in [
"81/tcp",
"127.0.0.1:bad->81/tcp",
"[::1]:0->81/tcp",
"[::]:65536->81/tcp",
"127.0.0.1:8081->81/udp",
] {
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
None
);
}
}
#[test]
fn skips_ssh_port_when_web_port_is_published() {
// gitea: SSH published before the web port, in podman's list order.
@@ -2071,10 +2071,6 @@ impl ProdContainerOrchestrator {
Ok(ReconcileAction::Left(reason))
if mode == ReconcileMode::ExistingOnly
&& reason == "absent"
// companion.rs owns missing UI provisioning/removal.
// Never resurrect an orphan from a stale snapshot.
// Existing UIs still pass through security config repair.
&& !super::companion::is_companion_app(&app_id)
&& (was_running.contains(&compute_container_name(&lm.manifest))
// The durable answer, and the one that does not
// erode. `was_running` only records what was
@@ -7229,52 +7225,6 @@ app:
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
}
#[tokio::test]
async fn reconcile_existing_does_not_resurrect_orphaned_companions() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
let companions = [
"bitcoin-ui",
"electrs-ui",
"lnd-ui",
"fedimint-ui",
"cuprate-ui",
];
let mut names = Vec::new();
for id in companions {
let manifest = pull_manifest(id, "localhost/companion:local");
names.push(compute_container_name(&manifest));
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/companion"))
.await;
}
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
// Repeated passes must leave lifecycle ownership with companion.rs.
for _ in 0..3 {
let report = orch.reconcile_existing().await;
assert_eq!(report.actions.len(), companions.len());
assert!(report
.actions
.iter()
.all(|(_, action)| *action == ReconcileAction::Left("absent".into())));
assert!(report.failures.is_empty());
}
let calls = rt.calls();
for operation in [
"pull_image:",
"create_container:",
"start_container:",
"stop_container:",
"remove_container:",
] {
assert!(
!calls.iter().any(|call| call.starts_with(operation)),
"{calls:?}"
);
}
}
#[tokio::test]
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
// A non-baseline app (gitea) self-heals ONLY with installation
+38
View File
@@ -176,6 +176,8 @@ pub struct QuadletUnit {
/// for rotation-drift detection.
pub labels: Vec<(String, String)>,
pub devices: Vec<String>,
/// Namespaced sysctls (`Sysctl=k=v`), already allow-listed by the manifest.
pub sysctls: Vec<(String, String)>,
pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>,
pub entrypoint: Option<Vec<String>>,
@@ -307,6 +309,9 @@ impl QuadletUnit {
for dev in &self.devices {
let _ = writeln!(s, "AddDevice={dev}");
}
for (k, v) in &self.sysctls {
let _ = writeln!(s, "Sysctl={k}={v}");
}
for (name, ip) in &self.add_hosts {
let _ = writeln!(s, "AddHost={name}:{ip}");
}
@@ -521,6 +526,11 @@ impl QuadletUnit {
})
.collect(),
devices: app.devices.clone(),
sysctls: app
.sysctls
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
// Container always answers to its own name; manifest extras add the
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
@@ -1487,6 +1497,7 @@ app:
"RELAY_NAME=Archipelago Nostr Relay".into(),
],
devices: vec!["/dev/kvm".into()],
sysctls: vec![("net.ipv4.ip_forward".into(), "1".into())],
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
@@ -1503,6 +1514,7 @@ app:
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
assert!(s.contains("AddDevice=/dev/kvm"));
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
assert!(s.contains("ReadOnly=true"));
assert!(s.contains("NoNewPrivileges=true"));
@@ -1524,6 +1536,7 @@ app:
assert!(!s.contains("PublishPort="));
assert!(!s.contains("Environment="));
assert!(!s.contains("AddDevice="));
assert!(!s.contains("Sysctl="));
assert!(!s.contains("AddHost="));
assert!(!s.contains("ReadOnly="));
assert!(!s.contains("NoNewPrivileges="));
@@ -1621,6 +1634,31 @@ app:
assert!(!s.contains("Network=host"));
}
#[test]
fn from_manifest_renders_namespaced_sysctls() {
let yaml = r#"
app:
id: vpn-exit
name: VPN Exit
version: 1.0.0
container:
image: test/vpn:1.0.0
network: pasta
devices: [/dev/net/tun]
sysctls:
net.ipv4.ip_forward: "1"
security:
capabilities: [NET_ADMIN, NET_RAW]
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "vpn-exit").render();
assert!(s.contains("Network=pasta"));
assert!(s.contains("AddDevice=/dev/net/tun"));
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
assert!(s.contains("AddCapability=NET_ADMIN"));
}
#[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
+117 -1
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -54,6 +54,12 @@ pub struct AppDefinition {
#[serde(default)]
pub devices: Vec<String>,
/// Namespaced kernel parameters for the app's OWN network namespace
/// (podman `--sysctl`). Allow-listed to [`ALLOWED_SYSCTLS`] and rejected
/// under host networking, where they would change the host itself.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sysctls: BTreeMap<String, String>,
#[serde(default)]
pub interfaces: HashMap<String, AppInterface>,
@@ -1009,6 +1015,11 @@ impl AppManifest {
}
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
validate_sysctls(
&self.app.sysctls,
self.app.container.network.as_deref(),
&self.app.security.network_policy,
)?;
// Volume tmpfs_options: only meaningful for type: tmpfs.
for (i, v) in self.app.volumes.iter().enumerate() {
@@ -1342,6 +1353,45 @@ fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
Ok(())
}
/// Sysctls an app may set. Each is scoped to the container's own network
/// namespace, so it cannot reach the host. Packet forwarding is what a
/// routing app (a VPN exit) needs, and rootless `/proc/sys` is read-only
/// inside the container, so it can only be set at create time.
pub const ALLOWED_SYSCTLS: &[&str] = &["net.ipv4.ip_forward", "net.ipv6.conf.all.forwarding"];
fn validate_sysctls(
sysctls: &BTreeMap<String, String>,
network: Option<&str>,
network_policy: &str,
) -> Result<(), ManifestError> {
if sysctls.is_empty() {
return Ok(());
}
let host_network = match network {
Some(n) => n == "host",
None => network_policy == "host",
};
if host_network {
return Err(ManifestError::Invalid(
"sysctls require the app's own network namespace, not host networking".into(),
));
}
for (key, value) in sysctls {
if !ALLOWED_SYSCTLS.contains(&key.as_str()) {
return Err(ManifestError::Invalid(format!(
"sysctls.{key} is not allowed (allowed: {})",
ALLOWED_SYSCTLS.join(", ")
)));
}
if value != "0" && value != "1" {
return Err(ManifestError::Invalid(format!(
"sysctls.{key} must be \"0\" or \"1\""
)));
}
}
Ok(())
}
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
let path = std::path::Path::new(source);
if !path.is_absolute() {
@@ -2784,6 +2834,72 @@ app:
assert_eq!(m.app.ports[2].bind, "");
}
fn sysctl_manifest(network: &str, sysctls: &str) -> String {
format!(
r#"
app:
id: sysctl-app
name: Sysctl App
version: 1.0.0
container:
image: test/image:1.0.0
network: {network}
sysctls:
{sysctls}
"#
)
}
#[test]
fn forwarding_sysctls_parse_in_own_netns() {
let m = AppManifest::parse(&sysctl_manifest(
"pasta",
" net.ipv4.ip_forward: \"1\"\n net.ipv6.conf.all.forwarding: \"0\"",
))
.expect("allow-listed forwarding sysctls must validate");
assert_eq!(m.app.sysctls["net.ipv4.ip_forward"], "1");
assert_eq!(m.app.sysctls["net.ipv6.conf.all.forwarding"], "0");
}
#[test]
fn sysctls_absent_by_default_and_not_serialized() {
let m = AppManifest::parse(
"app:\n id: plain\n name: Plain\n version: 1.0.0\n container:\n image: test/image:1.0.0\n",
)
.unwrap();
assert!(m.app.sysctls.is_empty());
assert!(!serde_yaml::to_string(&m).unwrap().contains("sysctls"));
}
#[test]
fn unsafe_sysctls_are_rejected() {
let cases = [
(
sysctl_manifest("pasta", " kernel.core_pattern: \"|/bin/sh\""),
"not allowed",
),
(
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"2\""),
"must be \"0\" or \"1\"",
),
(
sysctl_manifest("host", " net.ipv4.ip_forward: \"1\""),
"own network namespace",
),
(
// No explicit network: the host policy still means the host netns.
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"1\"")
.replace(" network: pasta\n", "")
.replace(" sysctls:", " security:\n network_policy: host\n sysctls:"),
"own network namespace",
),
];
for (yaml, expected) in cases {
let msg = AppManifest::parse(&yaml).unwrap_err().to_string();
assert!(msg.contains(expected), "expected '{expected}', got: {msg}");
}
}
#[test]
fn reviewed_host_bind_exceptions_parse() {
let yaml = r#"
+1
View File
@@ -439,6 +439,7 @@ impl PodmanClient {
"devices": manifest.app.devices.iter().map(|d| {
serde_json::json!({"path": d})
}).collect::<Vec<_>>(),
"sysctl": manifest.app.sysctls,
"resource_limits": resource_limits,
"cap_add": cap_add,
"cap_drop": cap_drop,
+3
View File
@@ -712,6 +712,9 @@ impl ContainerRuntime for DockerRuntime {
for device in &manifest.app.devices {
cmd.arg("--device").arg(device);
}
for (key, value) in &manifest.app.sysctls {
cmd.arg("--sysctl").arg(format!("{key}={value}"));
}
// Environment variables
for env in &manifest.app.environment {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY tailwind.css /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY qrcode.js /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
#
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Copy the HTML file
COPY index.html /usr/share/nginx/html/
+98
View File
@@ -0,0 +1,98 @@
# syntax=docker/dockerfile:1.7
#
# Packages nostr-vpn (github.com/mmalmi/nostr-vpn) as the paid-exit seller
# daemon + its web control panel. Both apps/nostr-vpn and apps/nostr-vpn-web
# build from this one image (same binaries, different entrypoint/command),
# mirroring upstream's own umbrel/docker-compose.yml, which runs `daemon`
# and `web` as two containers sharing one /data volume with no network link
# between them — reviewed directly, not assumed.
#
# This is upstream's own umbrel/Dockerfile, unchanged except for how the
# source arrives (a pinned commit tarball here, instead of a local checkout
# in their build context) — see docs/nostr-vpn-integration-plan.md for why
# the pin exists and what was verified against this exact commit.
ARG NVPN_COMMIT=87f19447741998ab5a06aadc701abc7ae021004b
FROM debian:bookworm-slim AS source
ARG NVPN_COMMIT
# git clone, not a codeload.github.com/archive/<sha>.tar.gz tarball: the
# latter 404s from this environment even for refs/heads/main HEAD (network
# policy on that specific endpoint, not a real upstream 404 — plain
# `git clone https://github.com/...` works fine).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# GitHub's anonymous smart-HTTP upload-pack refuses to fetch an arbitrary
# SHA directly (only advertised refs) — fetch main by name and verify the
# pinned commit is actually what we land on, so a force-push to main can't
# silently swap out the reviewed code.
RUN git init -q . \
&& git remote add origin https://github.com/mmalmi/nostr-vpn.git \
&& git fetch -q --depth 1 origin master \
&& git checkout -q FETCH_HEAD \
&& test "$(git rev-parse HEAD)" = "${NVPN_COMMIT}" \
&& rm -rf .git
FROM node:24-bookworm AS web-builder
WORKDIR /work/web/control-panel
COPY --from=source /src/web/control-panel/package.json /src/web/control-panel/pnpm-lock.yaml ./
RUN --mount=type=cache,id=nostr-vpn-pnpm-store,target=/pnpm/store \
corepack enable \
&& corepack prepare pnpm@10.28.2 --activate \
&& pnpm install --frozen-lockfile --store-dir /pnpm/store
COPY --from=source /src/web/control-panel ./
RUN pnpm run build
FROM rust:1.94-bookworm AS rust-builder
ARG TARGETPLATFORM
WORKDIR /work
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
clang \
libclang-dev \
libdbus-1-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY --from=source /src/Cargo.toml /src/Cargo.lock ./
COPY --from=source /src/crates ./crates
COPY --from=source /src/vendor ./vendor
RUN --mount=type=cache,id=nostr-vpn-cargo-registry-${TARGETPLATFORM},target=/usr/local/cargo/registry \
--mount=type=cache,id=nostr-vpn-cargo-git-${TARGETPLATFORM},target=/usr/local/cargo/git \
--mount=type=cache,id=nostr-vpn-cargo-target-${TARGETPLATFORM},target=/work/target \
cargo build --release -p nvpn -p nostr-vpn-web \
&& mkdir -p /out \
&& cp /work/target/release/nvpn /out/nvpn \
&& cp /work/target/release/nostr-vpn-web /out/nvpn-web
FROM debian:bookworm-slim AS runtime
LABEL org.opencontainers.image.source="https://github.com/mmalmi/nostr-vpn" \
org.opencontainers.image.description="nostr-vpn, packaged as an Archipelago paid-exit seller app" \
org.opencontainers.image.licenses="MIT"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 \
iptables \
iputils-ping \
libdbus-1-3 \
procps \
wireguard-tools \
&& rm -rf /var/lib/apt/lists/*
COPY --from=rust-builder /out/nvpn /usr/local/bin/nvpn
COPY --from=rust-builder /out/nvpn-web /usr/local/bin/nvpn-web
COPY --from=web-builder /work/web/control-panel/dist /usr/share/nostr-vpn/web
COPY docker-entrypoint.sh /usr/local/bin/archy-nvpn-entrypoint.sh
RUN chmod +x /usr/local/bin/archy-nvpn-entrypoint.sh
ENV HOME=/data/home \
XDG_CONFIG_HOME=/data/config \
NVPN_CLI_PATH=/usr/local/bin/nvpn \
RUST_LOG=info
EXPOSE 38080
VOLUME ["/data"]
# No image-level ENTRYPOINT/CMD: apps/nostr-vpn and apps/nostr-vpn-web set
# their own entrypoint/custom_args in their manifests (daemon vs. web),
# both pointing at archy-nvpn-entrypoint.sh — see that script for why the
# seed-config step has to run before either binary starts.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Shared entrypoint for both apps/nostr-vpn (daemon) and apps/nostr-vpn-web
# (control panel) -- they're the same image, differing only in the args
# this script execs into (see each manifest's container.entrypoint/custom_args).
#
# Seeds a minimal config.toml with our chosen listen_port BEFORE nvpn's own
# bootstrap (config_bootstrap.rs::load_or_default_config) ever runs, so the
# very first boot never has to self-heal off upstream's default 51820 --
# archy-x250 fleet nodes already run archipelago-wg on that port (found in
# Phase 0 testing, see docs/nostr-vpn-integration-plan.md). Every AppConfig
# field has #[serde(default = ...)], confirmed by reading
# crates/nostr-vpn-core/src/config/types.rs directly, so a partial TOML here
# merges cleanly with nvpn's own defaults (including the self-generated
# Nostr seller identity) instead of needing a full config.
#
# Never overwrites an existing config.toml: this volume may already hold a
# seller's identity, wallet, and pending Cashu credit adopted from the old
# root-mode install (/var/lib/archipelago/nostr-vpn) -- clobbering it would
# be a real funds-safety bug, not just a config reset.
set -eu
NVPN_LISTEN_PORT="${NVPN_LISTEN_PORT:-51822}"
CONFIG_DIR=/data/config/nvpn
CONFIG_PATH="$CONFIG_DIR/config.toml"
mkdir -p "$CONFIG_DIR" /data/home
if [ ! -f "$CONFIG_PATH" ]; then
cat > "$CONFIG_PATH" <<EOF
[node]
listen_port = ${NVPN_LISTEN_PORT}
EOF
chmod 600 "$CONFIG_PATH"
echo "nostr-vpn: seeded $CONFIG_PATH with listen_port=${NVPN_LISTEN_PORT} (first boot)"
fi
exec "$@"
+1
View File
@@ -35,6 +35,7 @@ As of the current `1.8-alpha` workstream:
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
- Data ownership repair is represented with `container.data_uid`.
- Per-app network-namespace kernel parameters are represented with `app.sysctls`, allow-listed to packet forwarding (added for rootless VPN exits such as nostr-vpn).
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
+1
View File
@@ -124,6 +124,7 @@ app:
| `app.environment` | Static `KEY=value` environment entries |
| `app.health_check` | HTTP or TCP health check settings |
| `app.devices` | Explicit device paths |
| `app.sysctls` | Namespaced packet-forwarding sysctls for the app's own network namespace (allow-listed; not with host networking) |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
+4
View File
@@ -74,6 +74,7 @@ because a wrong source produces a confident wrong verdict.
| `environment` | list of string | — | `- KEY=value` pairs (static). |
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
| `sysctls` | map | — | Kernel parameters for the app's **own** network namespace (podman `--sysctl`, Quadlet `Sysctl=`). Allow-list: `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`; values `"0"`/`"1"`. Rejected under host networking. Needed by routing apps because rootless `/proc/sys` is read-only inside the container. |
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
@@ -116,6 +117,9 @@ Validation (enforced at `AppManifest::validate()`):
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
- `network_policy` must be exactly `isolated`, `bridge`, or `host`.
- No `container:`/`ns:` network modes; devices must be `/dev/*`.
- `sysctls` keys must be on `ALLOWED_SYSCTLS` (packet forwarding only) and
need the app's own network namespace — never host networking, where they
would change the host.
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
exceptions: the rootless podman socket and dbus).
- `derived_env` templates may only use the placeholder allow-list;
+4 -127
View File
@@ -1,10 +1,6 @@
# Next OTA and raw ISO after 1.8.21
**Status: implementation and final OTA/raw ISO acceptance passed; draft upload verification and offline signing/publication remain.**
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
The chronological notes below retain earlier failures and superseded candidates;
the final tested source is `6d5f3ffb`.
**Status: implementation and acceptance in progress; NOT ready to release.**
This is the consolidated execution checklist for the operator's chat requests.
A targeted node repair is not completion of the release. Finish the remaining
@@ -49,18 +45,16 @@ completed. See PR review for the accepted scope and coverage limits.
## Final release checklist
- [x] Finish new-scope implementation and release acceptance; full-chain Angor
indexing still depends on the dev node finishing initial sync.
- [ ] Finish all new-scope implementation and specific acceptance above.
- [x] Remove disposable fixtures and temporary test overrides; verify native
Bitcoin/LND identity and start-state baselines remain protected.
- [x] Commit and push completed source changes to git and ngit.
- [x] Run final backend/UI/regression/release gates on the final source; inspect
- [ ] Run final backend/UI/regression/release gates on the final source; inspect
skipped tests and report actual hardware/runtime coverage.
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a
migration before they have backup/recovery support.
- [ ] Version/changelog and OTA payload prepared, validated and signed by user.
- [x] Raw ISO built; payload hashes/content verified; full installation and
installed-system boot tested in QEMU/KVM without network.
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
git and ngit; independently read back hashes and update discovery.
- [ ] Provide LAN scp command for the new raw ISO.
@@ -340,120 +334,3 @@ repository branch and Compose content from its own network namespace.
Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update
manifest is published by the version commit. Optimized candidate deployment,
artifact inspection, ISO smoke/boot checks and offline signatures follow.
### Release blocker discovered during candidate observation: scheduled doctor
The initial `02b840f2` 1.8.22 candidate is rejected for release. On the X250,
2026-09-30 21:10–21:11 UTC, the scheduled `archipelago-doctor.service` explicitly
ran `podman stop --all --time 30`, killed rootless network helpers and ran
`podman system migrate` after a two-attempt external network probe failed.
The journal attributes the stop to that unit, not the app health monitor or a
host reboot. All apps restarted, including Bitcoin, LND and the production site.
The earlier unchanged-container acceptance applies only to immediate deployment;
the later observation failed and must not be represented as a stability pass.
No persistent-data loss has been established. Keep this distinct from the closed
Framework incident; do not wipe or recreate any wallet as a recovery action.
Containment: stopped doctor timers on both test boxes, installed a safe diagnostic
script into both the executable and runtime payload, and rejected/stopped the
old ISO build. Network failure now produces a warning without stopping apps,
killing network processes, migrating Podman or deleting network state. Repeated
failures remain warnings, never a successful repair/check. Regression cases cover
healthy, absent network, non-root invocation, host failure, transient recovery,
repeated endpoint failure and namespace access failure, with mutation tripwires.
Live scheduled-cycle observation and final rebuilt-artifact acceptance are pending.
Recovery also exposed retired `git.tx1138.com` nginx base references in six
companion UI Dockerfiles. They now use the existing primary registry at the same
pinned version. All six images built successfully against that registry; payload
validation rejects the retired host before OTA/ISO packaging.
The post-recovery X250 check passes: Bitcoin authenticated RPC responds and IBD
advances; NPM/Gitea/Portainer APIs respond; Portainer's real namespace fetches
`demo-portainer` at `3ae171d6b0c728665a860520fe393c0abb772798` and its Compose
file; Portainer's original persistent mounts match the earlier backup evidence;
LND wallet/channel databases remain present on their persistent mount. No new
pre-incident cryptographic wallet-identity baseline was available, so these checks
must not be described as an exact identity/balance comparison.
The dev all-container observation also caught a separate Cuprate UI orphan loop:
`companion.rs` removed it because Cuprate was not installed, while generic desired-
state recovery resurrected it from an old running snapshot, using a unit without
nginx's required capabilities. Generic desired-state recovery now excludes missing companions
owned by `companion.rs`; existing companion provisioning/reaping remains the
single owner. Running UIs still receive the existing security configuration repairs. Regression runs repeated reconciliation against stale companion
snapshots and checks that no image/container lifecycle operations occur.
Safe-doctor live acceptance: the X250 completed a 12-minute observation with all
running container IDs, start times and data mounts unchanged. Its journal records
successful doctor runs at 21:22:06, 21:27:51 and 21:33:10 UTC. Both doctor timers
are restored with the safe script. Dev's native Bitcoin/LND stayed running;
all-container dev acceptance remains pending the companion-loop backend fix.
Final-source UI suite: 1,133 passed. Heavy backend compilation is serialized with
remaining build steps to reduce memory/IO pressure on the syncing dev node.
Final source release gates at `96fb5a4f`: 1,613 backend tests passed, zero failed,
four explicitly ignored; 1,133 UI tests passed; type-check, production UI build,
catalog/trust, shell, pruning, LND readiness, NPM migration and doctor regressions
passed. The isolated companion-loop regression passed independently as well.
ISO cache hardening: the installer now carries the current doctor script and
service/timer separately from rootfs.tar and overwrites both historical and active
script locations before first boot. This prevents a cached base image restoring
the old recovery code. A regression executes the actual installer block against
stale disposable files twice and confirms a missing safety payload fails closed.
The mounted-ISO smoke test also compares all three overlay files to source.
The final ISO build captures the exact newly deployed OTA UI/runtime payload.
### Final kiosk acceptance found nondeterministic NPM launch selection
Do not publish the staged `d1bc1273` candidate. NPM itself remains healthy and its
API, Portainer integration, site, and native services passed stability checks.
However, final kiosk acceptance found its card stuck at "Web UI not ready".
The runtime reported bindings in proxy-HTTP, proxy-HTTPS, admin order. The scanner
chose the first non-database/SSH binding, then rejected its tunnel-only host port
as unreachable on loopback, leaving the launch address empty. Earlier tests had
passed with admin first. This is a confirmed order-dependent scanner defect.
The candidate fix explicitly resolves NPM container port 81 to its actual host
allocation. Proxy ports never become the admin URL. Missing/malformed admin
bindings do not fall back to another service when published bindings are present.
Port parsing handles IPv6 authorities and rejects invalid ports. Regressions
cover all six three-port permutations, allocated admin ports, IPv4/IPv6 binding
strings, missing admin mappings, missing runtime port information, and malformed
or UDP bindings. Full backend regression execution is pending for this change.
The ISO build is frozen at installer-environment creation; no release was signed
or published. Rebuild/revalidate the OTA and ISO with this correction.
The companion orphan fix worked live: Cuprate UI was automatically removed and
all installed app container IDs remained unchanged. One observation helper raced
that expected removal between `podman ps` and `inspect`; it now excludes that
known orphan before inspection and repeats the stability check. This was a test
snapshot race, not another installed-app restart.
NPM selector final backend gate passed: 1,617 tests, zero failures, four explicitly
ignored, through the isolated runner. This includes all new port-selection cases
and the existing companion security/configuration and lifecycle regressions.
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
## Final accepted artifacts — 1.8.22-alpha
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
on both boxes passed. No installed app was restarted by the safe diagnostics,
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
were preserved during final management deployment.
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
files plus the backend. Full offline installation and installed UEFI boot to the
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
paths and the installed backend have the expected hashes. No VM wallet was set up.
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
coverage and limits. Draft upload verification, offline signatures, publication
and public readback remain; the fleet still advertises 1.8.21 until those gates
finish. Do not confuse a draft asset or source push with completed publication.
+2
View File
@@ -43,6 +43,8 @@ PublishPort=<bind>:<host>:<container>/<proto>
Environment=<KEY>=<value> # non-secret env only
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
Volume=<source>:<target><opts>
AddDevice=<path> # manifest devices
Sysctl=<key>=<value> # manifest sysctls (own netns, allow-listed)
ReadOnly=true # when security.readonly_root
NoNewPrivileges=true # when security.no_new_privileges
HealthCmd=<cmd> # from the health_check block
-83
View File
@@ -1,83 +0,0 @@
# Archipelago 1.8.22-alpha acceptance
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
## Verified application and runtime changes
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
- Six companion dashboard images built using the current registry.
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
## Live acceptance
The exact OTA bytes were deployed to the development box and ThinkPad X250.
Native Bitcoin/LND and the X250 production site retained their container identity
and start time during these final management deployments. Both boxes completed
12-minute observations including scheduled diagnostics with running containers
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
visible login/admin page, readable inline Bitcoin version choices and pruning
checkbox passed. No Bitcoin installation was triggered by this test.
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
AIUI embedding layers. Standalone AIUI retains its wallpaper.
Portainer's actual production network namespace fetched Git refs and the Compose
file after final deployment. Original mounts were preserved. Earlier disposable
fresh/reverse-install and migration/rollback tests, and the production host's
operator-initiated reboot check, passed.
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
change, rejected-payment refund, exact file bytes, Files access and free repeat
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
closed; the open pull-request list is empty.
## Boundaries
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
subprocess test and creation of a production Minibits profile were not run.
- The previously recorded unsafe-doctor incident changed X250 container start
times before the final fix. Persistent databases were present after recovery,
but no pre-incident cryptographic wallet-identity baseline was available.
Do not describe recovery evidence as an exact pre-incident balance comparison.
- No claim of perfect behavior on every device, network or future failure is made.
## Raw ISO acceptance
The raw ISO is 2,755,072,000 bytes. SHA-256:
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
hooks, build contexts, current doctor overlay, crash-capture configuration,
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
files match exactly. AIUI metadata names the tested source commit.
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
64 GiB NVMe virtual disk and no network completed the full installation. This
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
bootloader and initramfs generation. Cold boot with the ISO detached reached the
visible Welcome to Archipelago setup screen. The installed backend and both
historical/current doctor paths matched source hashes. Backend/nginx were active;
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
No wallet was initialized in this disposable VM.
The first automatic VM reboot selected the still-attached installer ISO. That
was corrected in the test configuration by detaching the ISO and explicitly
booting NVMe. It was not accepted as an installed-system boot. The subsequent
cold boot above is the successful acceptance run.
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
after the ISO build and VM acceptance.
## Publication pending
Artifacts are staged in a draft release. Upload/readback verification and the
operator's offline signatures must complete before promoting the fleet manifest
and signed app catalog or publishing the Git/ngit releases.
@@ -2607,11 +2607,6 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
echo " ✅ Bundled image-versions.sh"
fi
# Always overlay the current doctor, including when rootfs.tar is cached.
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
# Build-source apps need their complete contexts even on unbundled ISOs.
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
@@ -3235,18 +3230,6 @@ for test_script in run-e2e-tests.sh run-post-install-tests.sh; do
fi
done
# BEGIN DOCTOR OVERLAY
# Replace both the active and historical script locations before first boot.
# A cached rootfs can contain the unsafe network recovery implementation.
mkdir -p /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts
for doctor_dir in /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts; do
install -m 755 "$BOOT_MEDIA/archipelago/scripts/container-doctor.sh" "$doctor_dir/container-doctor.sh" || exit 1
done
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
done
# END DOCTOR OVERLAY
# Copy self-update script
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/
@@ -369,10 +369,6 @@ init()
<span class="text-xs text-white/40">September 30, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.</p>
<p>Network diagnostic failures no longer stop all apps or rebuild shared container networking.</p>
<p>Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.</p>
<p>Fixed companion dashboard builds still referencing a retired image registry.</p>
<p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p>
<p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p>
<p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p>
-2
View File
@@ -27,8 +27,6 @@ def check(root: Path) -> int:
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
if 'git.tx1138.com/' in dockerfile.read_text():
raise ValueError(f'{app["id"]}: Dockerfile references retired registry git.tx1138.com')
count += 1
return count
+102 -31
View File
@@ -32,8 +32,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FIXES_APPLIED=0
CHECKS_PASSED=0
CHECKS_WARNED=0
WARNING_NAMES=()
FIX_NAMES=()
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
@@ -85,13 +83,7 @@ run_fix() {
FIXES_APPLIED=$((FIXES_APPLIED + 1))
FIX_NAMES+=("$name")
else
local status=$?
if [ "$status" = 1 ]; then
CHECKS_PASSED=$((CHECKS_PASSED + 1))
else
CHECKS_WARNED=$((CHECKS_WARNED + 1))
WARNING_NAMES+=("$name")
fi
CHECKS_PASSED=$((CHECKS_PASSED + 1))
fi
}
@@ -454,33 +446,114 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
}
# ── Check 8: Rootless network egress (diagnostic only) ──────
# A single external endpoint or nsenter failure cannot establish that the
# containers have lost connectivity. In particular, entering only the network
# namespace can fail for rootless user namespaces. Never stop apps, kill network
# helpers, migrate Podman, or remove network state in response to this probe.
# Return 1 for healthy/not applicable and 2 for an inconclusive warning.
check_rootless_netns_egress() {
# ── Fix 8: Rootless netns egress lost ────────────────────────
# Rootless podman uses pasta to give containers internet egress. If pasta's
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
# restart storm), the rootless-netns keeps inter-container traffic working
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
# fail. The repair must rebuild the netns from scratch: merely cycling the
# containers reuses the existing (broken) netns because its holders
# (aardvark-dns, podman's pause process) survive — observed on a test node
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
# every timer run for ~an hour without ever restoring egress. So: stop the
# containers, kill the netns holders, `podman system migrate`, clear the
# stale netns state, then start everything back up.
#
# Destructive-action latch: cycling the whole fleet is a last resort. After
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
# until a run observes egress healthy again, which resets the counter.
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
NETNS_CYCLE_MAX=3
fix_rootless_netns_egress() {
# Needs root for nsenter. When doctor runs as the rootless container owner,
# a failed nsenter probe is a permissions artifact, not evidence of broken
# egress; do not cycle the fleet from that context.
[ "$(id -u)" = "0" ] || return 1
local archi_uid aardvark_pid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -n "$aardvark_pid" ] || return 1
local archi_uid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
# Locate the rootless-netns via aardvark-dns (it lives inside it).
local aardvark_pid
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
# Host precheck: if the host itself can't reach the internet, no point
# cycling containers — this is an upstream problem.
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log "WARNING: host connectivity probe failed; external endpoint may be unavailable. Apps left running."
return 2
fi
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1
fi
# Probe egress from inside the rootless-netns. One probe is noisy;
# require two consecutive failures 10s apart to rule out transients.
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
return 1 # first probe succeeded
fi
sleep 10
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
[ -z "$aardvark_pid" ] && return 1
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE"
return 1 # recovered on its own
fi
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
# isn't fixing it.
local failures
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
case "$failures" in *[!0-9]*|"") failures=0;; esac
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
return 1
fi
log "WARNING: rootless network probe inconclusive (endpoint, connectivity, or namespace access). Inspect affected apps before repair. Apps left running."
return 2
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
local running
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
if [ -z "$running" ]; then
log " No running containers to cycle — skipping"
return 1
fi
local count
count=$(echo "$running" | wc -l)
log " Stopping $count running containers (graceful, 30s)..."
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
sleep 5
# Tear the broken netns down for real: kill its holders and drop the
# stale state so the first container start rebuilds pasta + aardvark-dns
# from scratch. Without this, podman re-enters the old netns and the
# missing pasta tap never comes back.
log " Rebuilding rootless netns (killing holders, clearing state)..."
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
pkill -U "$archi_uid" -x pasta 2>/dev/null
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
sleep 2
$PODMANCMD system migrate >/dev/null 2>&1
rm -rf "/run/user/$archi_uid/containers/networks"
log " Starting containers back up..."
for c in $running; do
$PODMANCMD start "$c" >/dev/null 2>&1 &
done
wait
sleep 5
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log " Rootless-netns egress restored ($count containers cycled)"
rm -f "$NETNS_CYCLE_STATE"
else
failures=$((failures + 1))
echo "$failures" > "$NETNS_CYCLE_STATE"
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
fi
return 0
}
# ── Fix 9: Restart stopped core containers ──────────────────
@@ -658,7 +731,7 @@ run_fix "tor-permissions" fix_tor_permissions
run_fix "searxng" fix_searxng
run_fix "bitcoin-txindex" fix_bitcoin_txindex
run_fix "exit-127" fix_exit_127
run_fix "netns-egress" check_rootless_netns_egress
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts
@@ -667,9 +740,7 @@ run_fix "catatonit" fix_missing_catatonit
run_fix "dialout" fix_archipelago_dialout
echo ""
if [ "$CHECKS_WARNED" -gt 0 ]; then
log "Done: $CHECKS_WARNED unresolved warnings (${WARNING_NAMES[*]}), $FIXES_APPLIED fixes applied, $CHECKS_PASSED checks passed"
elif [ $FIXES_APPLIED -gt 0 ]; then
if [ $FIXES_APPLIED -gt 0 ]; then
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
else
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
-19
View File
@@ -71,25 +71,6 @@ else
bad "incomplete app build payload"
fi
# The cached rootfs must never restore the unsafe historical doctor on boot.
for doctor_file in container-doctor.sh archipelago-doctor.service archipelago-doctor.timer; do
if [[ "$doctor_file" == container-doctor.sh ]]; then
doctor_source="$REPO/scripts/$doctor_file"
else
doctor_source="$REPO/image-recipe/configs/$doctor_file"
fi
if cmp -s "$doctor_source" "$MNT/archipelago/scripts/$doctor_file"; then
ok "current doctor payload: $doctor_file"
else
bad "missing/stale doctor overlay: $doctor_file"
fi
done
if grep -Fq '# BEGIN DOCTOR OVERLAY' "$MNT/archipelago/auto-install.sh"; then
ok "installer replaces cached doctor before first boot"
else
bad "installer lacks cached doctor replacement"
fi
# ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
-6
View File
@@ -40,12 +40,6 @@ class BuildPayloadTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
contexts.check(self.root)
def test_retired_registry_rejected(self):
target = self.root / 'docker/lnd-ui/Dockerfile'
target.write_text('FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine\n')
with self.assertRaisesRegex(ValueError, 'lnd-ui.*retired registry'):
contexts.check(self.root)
def test_empty_payload_rejected(self):
shutil.rmtree(self.root / 'apps')
with self.assertRaisesRegex(ValueError, 'No app manifests'):
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Simulate failures without namespaces, network access, or real repair commands.
set -euo pipefail
source "$(dirname "$0")/../../scripts/container-doctor.sh"
id() { if [[ "$*" == '-u archipelago' ]]; then echo 1000; else echo "${TEST_UID:-0}"; fi; }
pgrep() { if [[ "$HAS_NETWORK" == 1 ]]; then echo 123; else return 1; fi; }
sleep() { :; }
# Any mutation fails the test immediately, including within command substitution.
tripwire() { echo 'FAIL: diagnostic attempted a mutation' >&2; exit 99; }
podman() { tripwire; }
podman_rootless() { tripwire; }
sudo() { tripwire; }
systemctl() { tripwire; }
pkill() { tripwire; }
kill() { tripwire; }
rm() { tripwire; }
mkdir() { tripwire; }
timeout() {
if [[ "$2" == bash ]]; then return "$HOST_STATUS"; fi
[[ "$2" == nsenter ]] || exit 98
PROBES=$((PROBES + 1))
if [[ "$PROBES" == 1 ]]; then return "$FIRST_STATUS"; fi
return "$SECOND_STATUS"
}
check_case() {
local label=$1 expected=$2 expected_probes=$3
PROBES=0
local status=0
check_rootless_netns_egress > /dev/null || status=$?
[[ "$status" == "$expected" && "$PROBES" == "$expected_probes" ]] || {
echo "FAIL: $label status=$status probes=$PROBES"; exit 1;
}
echo "PASS: $label"
}
HAS_NETWORK=1 HOST_STATUS=0 FIRST_STATUS=0 SECOND_STATUS=0
check_case healthy 1 1
TEST_UID=1000 check_case rootless-caller 1 0
HAS_NETWORK=0 check_case no-network 1 0
HOST_STATUS=1 check_case host-offline 2 0
FIRST_STATUS=1 check_case transient-recovery 1 2
FIRST_STATUS=1 SECOND_STATUS=1 check_case repeated-egress-failure 2 2
FIRST_STATUS=126 SECOND_STATUS=126 check_case namespace-access-failure 2 2
# Failure must remain an unresolved warning on every scheduled invocation.
FIRST_STATUS=1 SECOND_STATUS=1
for attempt in 1 2 3 4 5; do
PROBES=0
run_fix netns-egress check_rootless_netns_egress > /dev/null
done
[[ "$CHECKS_WARNED" == 5 && "$FIXES_APPLIED" == 0 && "$CHECKS_PASSED" == 0 ]]
FIRST_STATUS=0 PROBES=0
run_fix netns-egress check_rootless_netns_egress > /dev/null
[[ "$CHECKS_PASSED" == 1 && "$FIXES_APPLIED" == 0 ]]
echo 'PASS: repeated failure warnings never trigger repair or report a successful check'
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
"""Execute the installer's actual overlay against a stale disposable rootfs."""
import pathlib, subprocess, tempfile, shutil, unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
class DoctorOverlayTests(unittest.TestCase):
def test_cached_rootfs_and_missing_payload(self):
source = (ROOT / 'image-recipe/_archived/build-auto-installer-iso.sh').read_text()
block = source.split('# BEGIN DOCTOR OVERLAY\n', 1)[1].split('# END DOCTOR OVERLAY', 1)[0]
with tempfile.TemporaryDirectory() as temp:
base = pathlib.Path(temp)
target = base / 'target'
media = base / 'media'
payload = media / 'archipelago/scripts'
payload.mkdir(parents=True)
units = target / 'etc/systemd/system'
units.mkdir(parents=True)
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
dest = target / directory
dest.mkdir(parents=True)
(dest / 'container-doctor.sh').write_text('UNSAFE OLD SCRIPT')
files = [ROOT / 'scripts/container-doctor.sh',
ROOT / 'image-recipe/configs/archipelago-doctor.service',
ROOT / 'image-recipe/configs/archipelago-doctor.timer']
for path in files:
shutil.copyfile(path, payload / path.name)
script = block.replace('/mnt/target', str(target))
for _ in range(2):
subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, check=True)
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
dest = target / directory / 'container-doctor.sh'
self.assertEqual(dest.read_bytes(), files[0].read_bytes())
self.assertEqual(dest.stat().st_mode & 0o777, 0o755)
for path in files[1:]:
self.assertEqual((units / path.name).read_bytes(), path.read_bytes())
(payload / 'container-doctor.sh').unlink()
failed = subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, capture_output=True)
self.assertNotEqual(failed.returncode, 0, 'Missing safety overlay must fail installation')
if __name__ == '__main__':
unittest.main()
-2
View File
@@ -74,8 +74,6 @@ stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml -
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py
stage "iso-doctor-overlay" python3 tests/regression/iso-doctor-overlay.py
stage "doctor-egress" bash tests/regression/container-doctor-egress.sh
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs