Compare commits

..
57 Commits
Author SHA1 Message Date
archipelago e474a2b4c9 chore: sync generated release artifacts 2026-06-12 03:15:24 -04:00
archipelago 00c32688f8 chore: release v1.7.85-alpha 2026-06-12 03:14:59 -04:00
archipelago d6f108d818 chore: snapshot release workspace 2026-06-12 03:00:15 -04:00
archipelago 6a30ff11bd chore: release v1.7.84-alpha 2026-06-11 04:44:58 -04:00
archipelago 22df3f8f5f chore: release v1.7.83-alpha 2026-06-11 03:03:32 -04:00
archipelago 87853fc29c frontend: keep mobile app tabs singular 2026-06-11 02:54:34 -04:00
archipelago b7c2fd081f settings: update whats new for v1.7.83 2026-06-11 02:49:07 -04:00
archipelago 809b76526e docs: prepare v1.7.83 alpha release notes 2026-06-11 02:40:04 -04:00
archipelago 760796f650 frontend: polish mesh release layout 2026-06-11 02:39:24 -04:00
archipelago 10e4f218a6 deploy: bound indeedhub fixups and polish bitcoin ui 2026-06-11 02:32:10 -04:00
archipelago 84b283f5b6 deploy: exclude archived image build outputs 2026-06-11 02:01:55 -04:00
archipelago 8f2e03df2a deploy: exclude codex scratch artifacts 2026-06-11 01:46:38 -04:00
archipelago c79afa9541 frontend: fix strict production build typing 2026-06-11 01:30:49 -04:00
archipelago f818f1dcc1 app-platform: remove unsupported saleor release surface 2026-06-11 01:16:21 -04:00
archipelago de60f7e21e app-platform: remove revoked onlyoffice app 2026-06-11 01:03:45 -04:00
archipelago 881478a873 app-platform: type manifest launch interfaces 2026-06-11 00:52:16 -04:00
archipelago 755ba5562d app-platform: derive launch URLs from manifests 2026-06-11 00:33:24 -04:00
archipelago 182f18ecf3 docs: capture 1.8 app migration release plan 2026-06-11 00:24:54 -04:00
archipelago 1a3d726eac frontend: polish app launch and release experience 2026-06-11 00:24:40 -04:00
archipelago c393b96da3 backend: harden rootless app lifecycle orchestration 2026-06-11 00:24:32 -04:00
archipelago 09ec64932f app-platform: generate catalog from app manifests 2026-06-11 00:24:20 -04:00
archipelago 9079d404d6 chore: ignore local build scratch artifacts 2026-06-11 00:23:42 -04:00
archipelago af9d531a00 chore: sync cargo lock for v1.7.82-alpha 2026-05-22 17:24:42 -04:00
archipelago 136eda16c9 chore: release v1.7.82-alpha 2026-05-22 17:19:45 -04:00
archipelago 626a89bdbc fix(apps): proxy saleor storefront media 2026-05-22 17:08:03 -04:00
archipelago 68784be4db chore: sync cargo lock for v1.7.81-alpha 2026-05-21 21:48:46 -04:00
archipelago 853d51ae14 chore: release v1.7.81-alpha 2026-05-21 21:44:14 -04:00
archipelago a578834462 fix(apps): repair saleor storefront startup 2026-05-21 21:33:51 -04:00
archipelago c31c3765f4 chore: sync cargo lock for v1.7.80-alpha 2026-05-21 00:39:53 -04:00
archipelago bdd5a2c43e chore: release v1.7.80-alpha 2026-05-21 00:38:57 -04:00
archipelago 8eb03d106e fix(apps): repair saleor storefront graphql origin 2026-05-21 00:30:22 -04:00
archipelago 4da6e3b43c chore: sync cargo lock for v1.7.79-alpha 2026-05-20 23:17:04 -04:00
archipelago 7be7420c4f chore: release v1.7.79-alpha 2026-05-20 23:11:54 -04:00
archipelago 34c4e87d14 feat(apps): add saleor storefront 2026-05-20 23:02:57 -04:00
archipelago e61c757633 chore: release v1.7.78-alpha 2026-05-20 20:53:23 -04:00
archipelago cc1f8fba72 fix(apps): stabilize saleor and netbird release paths 2026-05-20 20:38:52 -04:00
archipelago 556f2e7cac chore: release v1.7.77-alpha 2026-05-20 01:03:48 -04:00
archipelago 0898c54765 chore: bump version to v1.7.77-alpha 2026-05-20 00:38:26 -04:00
archipelago f4368785f0 fix(apps): unblock saleor and netbird first-use flows 2026-05-20 00:28:30 -04:00
archipelago 608f4c17f0 chore: release v1.7.76-alpha 2026-05-19 21:55:48 -04:00
archipelago 92c58141af fix(apps): stabilize saleor and netbird launch 2026-05-19 21:45:17 -04:00
archipelago 7b2f4cb05f chore: sync cargo lock for v1.7.75-alpha 2026-05-19 20:27:34 -04:00
archipelago e65e76cd9d chore: release v1.7.75-alpha 2026-05-19 20:19:24 -04:00
archipelago 6d03ed5a69 docs: add v1.7.75-alpha changelog 2026-05-19 20:11:41 -04:00
archipelago 522c046525 feat(apps): add saleor and harden netbird repair 2026-05-19 20:11:22 -04:00
archipelago 56f956973e chore: release v1.7.74-alpha 2026-05-19 19:29:15 -04:00
archipelago bd69ef41d5 fix(apps): repair netbird login and iframe focus 2026-05-19 19:21:43 -04:00
archipelago eeb08fc78f chore: release v1.7.73-alpha 2026-05-19 18:40:10 -04:00
archipelago 1836b035b4 fix(mobile): improve app store search and launches 2026-05-19 18:29:04 -04:00
archipelago 3e01e57c8d chore: release v1.7.72-alpha 2026-05-19 17:42:11 -04:00
archipelago ca3e2ee0ca fix(settings): update whats new release notes 2026-05-19 17:33:45 -04:00
archipelago 5859ef77e7 chore: release v1.7.71-alpha 2026-05-19 17:30:20 -04:00
archipelago f0bd49d03d fix(apps): repair netbird install and app icons 2026-05-19 17:20:32 -04:00
archipelago cede77f3bc chore: update release lockfile 2026-05-19 16:17:13 -04:00
archipelago dd8a6cd9d7 chore: release v1.7.70-alpha 2026-05-19 16:10:43 -04:00
archipelago ab96c97cb9 fix(apps): self-host netbird and stabilize app sessions 2026-05-19 16:02:35 -04:00
archipelago 881779005a chore: update release lockfile 2026-05-19 14:45:20 -04:00
283 changed files with 21131 additions and 6298 deletions
+7
View File
@@ -85,7 +85,14 @@ scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
Screenshot *.png
uploads/
@@ -132,6 +132,16 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
fun openExternalUrl(url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
}
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -220,13 +230,7 @@ fun WebViewScreen(
// Keep navigation within the Archipelago server
if (url.startsWith(serverUrl)) return false
// Open external URLs in the system browser
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
openExternalUrl(url)
return true
}
}
@@ -243,18 +247,30 @@ fun WebViewScreen(
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
// Extract the URL from the hit test
val data = view?.hitTestResult?.extra
if (data != null) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(data),
)
context.startActivity(intent)
} catch (_: Exception) {}
val transport = resultMsg?.obj as? WebView.WebViewTransport
?: return false
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
openExternalUrl(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) openExternalUrl(url)
view?.stopLoading()
}
}
}
return false
transport.webView = popup
resultMsg.sendToTarget()
return true
}
}
+130
View File
@@ -1,5 +1,135 @@
# Changelog
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
## v1.7.84-alpha (2026-06-11)
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
## v1.7.83-alpha (2026-06-11)
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release.
- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing.
- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring.
- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient.
- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy.
- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase.
## v1.7.82-alpha (2026-05-22)
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
## v1.7.81-alpha (2026-05-21)
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
## v1.7.80-alpha (2026-05-21)
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
## v1.7.79-alpha (2026-05-20)
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
## v1.7.78-alpha (2026-05-20)
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
## v1.7.77-alpha (2026-05-20)
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
## v1.7.76-alpha (2026-05-20)
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
## v1.7.74-alpha (2026-05-19)
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.73-alpha (2026-05-19)
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
## v1.7.72-alpha (2026-05-19)
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.70-alpha (2026-05-19)
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.69-alpha (2026-05-19)
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
+1 -1
View File
@@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet endurain morphos-server router web5-dwn; do
for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"
+2 -2
View File
@@ -20,8 +20,8 @@
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (30)
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Self-Hosted Apps (29)
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
+252 -62
View File
@@ -14,7 +14,7 @@
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
@@ -25,8 +25,8 @@
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
"version": "28.4.0",
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",
@@ -38,7 +38,7 @@
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs",
"category": "money",
@@ -53,7 +53,7 @@
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor.",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
@@ -68,7 +68,7 @@
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
@@ -84,7 +84,7 @@
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
@@ -99,7 +99,7 @@
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
@@ -110,49 +110,133 @@
"id": "botfights",
"title": "BotFights",
"version": "1.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": ["9100:9100"],
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": ["3001:3000", "2222:22"],
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
}
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Web-based file manager.",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": ["8083:80"],
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostr.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "meshtastic",
"title": "Meshtastic",
"version": "2-daily-alpine",
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
"icon": "/assets/img/app-icons/meshcore.svg",
"author": "Meshtastic",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
"repoUrl": "https://github.com/meshtastic/firmware",
"containerConfig": {
"ports": [
"4403:4403"
],
"volumes": [
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
],
"env": [
"MESHTASTIC_PORT=/dev/ttyUSB0",
"MESHTASTIC_SERIAL=true"
],
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
}
},
{
@@ -167,15 +251,19 @@
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": ["8082:80"],
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "2024.1.0",
"description": "Privacy-respecting metasearch engine.",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
@@ -183,21 +271,46 @@
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": ["8888:8080"],
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint",
"version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
@@ -209,8 +322,13 @@
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": ["8096:8096"],
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
@@ -227,34 +345,47 @@
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1",
"description": "Open-source home automation.",
"version": "2024.1.0",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": ["8123:8123"],
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
"env": ["TZ=UTC"]
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring dashboards.",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
"dockerImage": "grafana/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": ["3000:3000"],
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
"ports": [
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
@@ -269,27 +400,65 @@
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": ["8240:8240"],
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "0.71.2",
"description": "WireGuard mesh VPN client for secure remote access through NetBird Cloud or a self-hosted management server.",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/netbirdio/netbird:0.71.2",
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
"env": ["NB_SETUP_KEY=", "NB_MANAGEMENT_URL="],
"args": ["up"]
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
@@ -304,10 +473,20 @@
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": ["3002:3001"],
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
"env": ["TZ=UTC"],
"args": ["--", "node", "server/server.js"]
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
@@ -321,24 +500,35 @@
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": ["2342:2342"],
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "28",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": ["8085:80"],
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
}
]
+1 -3
View File
@@ -8,7 +8,6 @@
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| thunderhub | 3010 (HTTP) | v0.13.31 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
@@ -33,7 +32,6 @@
| ollama | 11434 | v0.5.4 |
| grafana | 3001 | v10.2.0 |
| portainer | 9000 | v2.19.4 |
| onlyoffice | 8088 | v7.5.1 |
| penpot | 8089 | v2.4 |
## Building Apps
@@ -44,7 +42,7 @@ cd apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
-4
View File
@@ -17,7 +17,6 @@ This document lists all port assignments for Archipelago apps.
| mempool | 4080 | TCP | Web UI | 14080 |
| ollama | 11434 | TCP | API | 21434 |
| searxng | 8888 | TCP | Web UI | 18888 |
| onlyoffice | 8088 | TCP | Web UI | 18088 |
| penpot | 8089 | TCP | Web UI | 18089 |
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
@@ -25,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
@@ -47,7 +45,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Mempool | http://localhost:14080 |
| Ollama | http://localhost:21434 |
| SearXNG | http://localhost:18888 |
| OnlyOffice | http://localhost:18088 |
| Penpot | http://localhost:18089 |
| LND REST | http://localhost:18080 |
| Core Lightning | http://localhost:19835 |
@@ -55,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Web5 DWN | http://localhost:13000 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
+2 -4
View File
@@ -30,14 +30,13 @@ cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
./build.sh web5-dwn
```
## Running Apps via Archipelago
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Web5 DWN**: http://localhost:13000
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet, web5-dwn)
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
+1 -3
View File
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **thunderhub** — Lightning management UI (v0.13.31)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
@@ -18,12 +17,11 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **web5-dwn** — Decentralized Web Node (v0.4.0)
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
+2 -2
View File
@@ -56,8 +56,8 @@ app:
endpoint: http://localhost:32838
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
+14 -3
View File
@@ -26,10 +26,19 @@ app:
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
@@ -37,6 +46,8 @@ app:
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
+10 -2
View File
@@ -28,11 +28,17 @@ app:
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
@@ -40,6 +46,8 @@ app:
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
+4 -2
View File
@@ -1,12 +1,12 @@
app:
id: botfights
name: BotFights
version: 1.0.0
version: 1.1.0
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
container:
image: git.tx1138.com/lfg2025/botfights:1.1.0
image: 146.59.87.168:3000/lfg2025/botfights:1.1.0
pull_policy: always
dependencies:
@@ -62,6 +62,8 @@ app:
metadata:
author: Dorian
repo: https://botfights.net
icon: /assets/img/app-icons/botfights.svg
license: MIT
tags:
- bitcoin
+6 -2
View File
@@ -60,8 +60,8 @@ app:
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
@@ -79,3 +79,7 @@ app:
port: 23000
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true
-3
View File
@@ -10,8 +10,6 @@ app:
pull_policy: if-not-present
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
@@ -40,7 +38,6 @@ app:
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:
-1
View File
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
});
+3 -3
View File
@@ -5,7 +5,7 @@ app:
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0
pull_policy: if-not-present
network: archy-net
data_uid: "1000:1000"
@@ -25,7 +25,7 @@ app:
resources:
cpu_limit: 0
memory_limit: 4Gi
memory_limit: 6Gi
disk_limit: 50Gi
security:
@@ -48,7 +48,7 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- CACHE_MB=1024
- MAX_SEND=10000000
health_check:
+22 -3
View File
@@ -5,9 +5,17 @@ app:
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
- |-
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
sleep 30;
done;
exec fedimintd
derived_env:
- key: FM_P2P_URL
template: fedimint://{{HOST_MDNS}}:8173
@@ -40,7 +48,9 @@ app:
- host: 8174
container: 8174
protocol: tcp
- host: 8175
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
- host: 8177
container: 8175
protocol: tcp
@@ -52,7 +62,7 @@ app:
environment:
- FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://host.archipelago:8332
- FM_BITCOIND_URL=http://bitcoin-knots:8332
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173
@@ -67,6 +77,15 @@ app:
timeout: 5s
retries: 3
interfaces:
main:
name: Guardian UI
description: Fedimint Guardian wait/proxy UI
type: ui
port: 8175
protocol: http
path: /
bitcoin_integration:
rpc_access: admin
sync_required: true
+81 -46
View File
@@ -1,52 +1,87 @@
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
icon: git-branch
port: 3000
internal_port: 3001
ssh_port: 2222
image: docker.io/gitea/gitea:1.23
tier: optional
app:
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
requires:
memory_mb: 256
disk_mb: 500
container:
image: docker.io/gitea/gitea:1.23
pull_policy: if-not-present
volumes:
- host: /var/lib/archipelago/gitea/data
container: /data
- host: /var/lib/archipelago/gitea/config
container: /etc/gitea
dependencies:
- storage: 500Mi
environment:
GITEA__database__DB_TYPE: sqlite3
GITEA__server__SSH_PORT: "2222"
GITEA__server__SSH_LISTEN_PORT: "22"
GITEA__server__LFS_START_SERVER: "true"
GITEA__packages__ENABLED: "true"
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
resources:
memory_limit: 256Mi
disk_limit: 500Mi
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
# new tab on host port 3001 instead of embedding it in an iframe.
nginx_proxy:
listen: 3000
proxy_pass: "http://127.0.0.1:3001"
extra_headers:
- "proxy_hide_header X-Frame-Options"
- "proxy_hide_header Content-Security-Policy"
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: false
network_policy: bridge
health_check:
endpoint: /
interval: 120
timeout: 5
retries: 3
ports:
- host: 3001
container: 3000
protocol: tcp
- host: 2222
container: 22
protocol: tcp
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight (SQLite, no external DB needed)
volumes:
- type: bind
source: /var/lib/archipelago/gitea/data
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/gitea/config
target: /etc/gitea
options: [rw]
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__SSH_PORT=2222
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
health_check:
type: http
endpoint: http://localhost:3000
path: /
interval: 120s
timeout: 30s
retries: 5
interfaces:
main:
name: Web UI
description: Gitea web interface
type: ui
port: 3001
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/gitea.svg
repo: https://gitea.com
tier: optional
launch:
open_in_new_tab: true
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+6 -2
View File
@@ -49,5 +49,9 @@ app:
endpoint: http://localhost:3000
path: /api/health
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
metadata:
launch:
open_in_new_tab: true
+26 -18
View File
@@ -1,29 +1,29 @@
app:
id: home-assistant
id: homeassistant
name: Home Assistant
version: 2024.1.0
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: homeassistant/home-assistant:2024.1
image_signature: cosign://...
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
memory_limit: 512Mi
disk_limit: 10Gi
security:
capabilities: [NET_BIND_SERVICE]
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW]
readonly_root: false # Home Assistant needs write access
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: host # Requires host network for device discovery
network_policy: isolated
apparmor_profile: home-assistant
ports:
@@ -36,24 +36,32 @@ app:
source: /var/lib/archipelago/home-assistant
target: /config
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
devices:
- /dev/ttyUSB0 # Serial devices
- /dev/ttyACM0 # USB devices
devices: []
environment:
- TZ=UTC
- PUID=1000
- PGID=1000
health_check:
type: http
endpoint: http://localhost:8123
path: /
type: tcp
endpoint: localhost:8123
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Home Assistant dashboard
type: ui
port: 8123
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/homeassistant.png
category: home
author: Home Assistant
repo: https://github.com/home-assistant/core
launch:
open_in_new_tab: true
+6 -5
View File
@@ -1,12 +1,12 @@
app:
id: indeedhub
name: Indeehub
version: 0.1.0
name: IndeeHub
version: 1.0.0
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: media
category: community
container:
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
pull_policy: always # Pull from registry; falls back to local build
network: indeedhub-net
@@ -70,8 +70,9 @@ app:
metadata:
author: Indeehub Team
icon: /assets/img/app-icons/indeedhub.png
website: https://indeedhub.com
source: https://github.com/indeedhub/indeedhub
repo: https://github.com/indeedhub/indeedhub
license: MIT
tags:
- bitcoin
+61
View File
@@ -0,0 +1,61 @@
app:
id: jellyfin
name: Jellyfin
version: 10.8.13
description: Free media server. Stream movies, music, and photos.
container:
image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 8096
container: 8096
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/jellyfin/config
target: /config
options: [rw]
- type: bind
source: /var/lib/archipelago/jellyfin/cache
target: /cache
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:8096
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Jellyfin media dashboard
type: ui
port: 8096
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/jellyfin.webp
category: data
author: Jellyfin
repo: https://github.com/jellyfin/jellyfin
+2 -2
View File
@@ -1,11 +1,11 @@
app:
id: lnd
name: Lightning Network Daemon
name: LND
version: 0.18.4
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present
network: archy-net
secret_env:
+3 -3
View File
@@ -1,11 +1,11 @@
app:
id: mempool
name: Mempool
version: 2.5.0
name: Mempool Explorer
version: 3.0.0
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: mempool/mempool:v2.5.0
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
image_signature: cosign://...
pull_policy: if-not-present
+23 -15
View File
@@ -1,13 +1,12 @@
app:
id: meshtastic
name: Meshtastic
version: 2.5.0
version: 2-daily-alpine
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
container:
image: meshtastic/meshtasticd:2.5.6
image_signature: cosign://...
pull_policy: verify-signature
image: docker.io/meshtastic/meshtasticd:daily-alpine
pull_policy: if-not-present
dependencies:
- storage: 1Gi
@@ -29,33 +28,42 @@ app:
ports:
- host: 4403
container: 4403
protocol: tcp # HTTP API
- host: 1883
container: 1883
protocol: tcp # MQTT (optional)
protocol: tcp # Meshtastic TCP API
devices:
- /dev/ttyUSB0 # LoRa radio device (if connected)
- /dev/ttyACM0 # Alternative device path
volumes:
- type: bind
source: /var/lib/archipelago/meshtastic
target: /app/data
target: /var/lib/meshtasticd
options: [rw]
files:
- path: /var/lib/archipelago/meshtastic/config.yaml
content: |
General:
MACAddress: AA:BB:CC:DD:EE:01
Webserver:
Port: 4403
environment:
- MESHTASTIC_PORT=/dev/ttyUSB0
- MESHTASTIC_SERIAL=true
health_check:
type: http
endpoint: http://localhost:4403
path: /health
type: cmd
endpoint: test -f /var/lib/meshtasticd/config.yaml
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
networking:
mesh_enabled: true
local_network_access: true
metadata:
icon: /assets/img/app-icons/meshcore.svg
category: networking
tier: recommended
repo: https://github.com/meshtastic/firmware
+59
View File
@@ -0,0 +1,59 @@
app:
id: nextcloud
name: Nextcloud
version: "29"
description: Your own private cloud. File sync, calendars, contacts.
container:
image: 146.59.87.168:3000/lfg2025/nextcloud:29
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8085
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/nextcloud
target: /var/www/html
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Nextcloud file and collaboration dashboard
type: ui
port: 8085
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/nextcloud.webp
category: data
author: Nextcloud
repo: https://github.com/nextcloud/server
launch:
open_in_new_tab: true
+3 -3
View File
@@ -28,7 +28,7 @@ app:
apparmor_profile: nostr-relay
ports:
- host: 8081
- host: 18081
container: 8080
protocol: tcp # HTTP/WebSocket
@@ -49,8 +49,8 @@ app:
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
nostr_integration:
relay_type: public
-5
View File
@@ -1,5 +0,0 @@
# OnlyOffice - uses official image
FROM onlyoffice/documentserver:7.5.0
# Default configuration is in the image
# No additional setup needed
-50
View File
@@ -1,50 +0,0 @@
app:
id: onlyoffice
name: OnlyOffice
version: 7.5.0
description: Office suite and document collaboration. Edit documents, spreadsheets, and presentations.
container:
image: onlyoffice/documentserver:7.5.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: false # OnlyOffice needs write access
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: onlyoffice
ports:
- host: 8088
container: 80
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/onlyoffice
target: /var/www/onlyoffice/Data
options: [rw]
environment:
- JWT_ENABLED=false
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
health_check:
type: http
endpoint: http://localhost:8088
path: /healthcheck
interval: 30s
timeout: 5s
retries: 3
+60
View File
@@ -0,0 +1,60 @@
app:
id: photoprism
name: PhotoPrism
version: "240915"
description: AI-powered photo management with facial recognition.
container:
image: 146.59.87.168:3000/lfg2025/photoprism:240915
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID]
readonly_root: false
network_policy: isolated
ports:
- host: 2342
container: 2342
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/photoprism
target: /photoprism/storage
options: [rw]
environment:
- PHOTOPRISM_ADMIN_PASSWORD=archipelago
- PHOTOPRISM_DEFAULT_LOCALE=en
health_check:
type: tcp
endpoint: localhost:2342
interval: 60s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: PhotoPrism photo library
type: ui
port: 2342
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/photoprism.svg
category: data
author: PhotoPrism
repo: https://github.com/photoprism/photoprism
launch:
open_in_new_tab: true
+64
View File
@@ -0,0 +1,64 @@
app:
id: portainer
name: Portainer
version: 2.19.4
description: Container management web UI for the local Podman socket.
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
pull_policy: if-not-present
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 9000
container: 9000
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/portainer
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/portainer/compose
target: /data/compose
options: [rw]
- type: bind
source: /run/user/1000/podman/podman.sock
target: /var/run/docker.sock
options: [rw]
environment: []
interfaces:
main:
name: Web UI
description: Portainer web interface
type: ui
port: 9000
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/portainer.webp
tier: optional
launch:
open_in_new_tab: true
features:
- Container management dashboard
- Local Podman socket access
- Compose stack storage
+2 -2
View File
@@ -45,5 +45,5 @@ app:
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
+54
View File
@@ -0,0 +1,54 @@
app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
description: Self-hosted uptime monitoring.
container:
image: 146.59.87.168:3000/lfg2025/uptime-kuma:1
pull_policy: if-not-present
network: pasta
custom_args: ["--", "node", "server/server.js"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID]
readonly_root: false
network_policy: isolated
ports:
- host: 3002
container: 3001
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/uptime-kuma
target: /app/data
options: [rw]
environment:
- TZ=UTC
health_check:
type: http
endpoint: localhost:3001
path: /
interval: 30s
timeout: 5s
retries: 3
metadata:
icon: /assets/img/app-icons/uptime-kuma.webp
category: data
tier: recommended
author: Uptime Kuma
repo: https://github.com/louislam/uptime-kuma
launch:
open_in_new_tab: true
+60
View File
@@ -0,0 +1,60 @@
app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8082
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/vaultwarden
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Vaultwarden web vault
type: ui
port: 8082
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/vaultwarden.webp
category: data
tier: recommended
author: Vaultwarden
repo: https://github.com/dani-garcia/vaultwarden
launch:
open_in_new_tab: true
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-38
View File
@@ -1,38 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENV DWN_STORAGE_PATH=/app/data
ENV DID_METHOD=key
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# Web5 DWN (Decentralized Web Node)
Personal data store for Web5. Store and sync your decentralized data across devices.
## Building
```bash
# From the apps directory
./build.sh web5-dwn
# Or manually
cd web5-dwn
docker build -t archipelago/web5-dwn:latest .
```
## Development
```bash
cd web5-dwn
npm install
npm run dev
```
## Ports
- **3000**: HTTP API (dev: 13000)
## Running Locally
```bash
docker run -p 3000:3000 \
-v /tmp/archipelago-dev/web5-dwn:/app/data \
-e DWN_STORAGE_PATH=/app/data \
archipelago/web5-dwn:latest
```
-55
View File
@@ -1,55 +0,0 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: archipelago/web5-dwn:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "web5-dwn",
"version": "1.0.0",
"description": "Decentralized Web Node for Web5",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-34
View File
@@ -1,34 +0,0 @@
import express from 'express';
const app = express();
const port = 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'web5-dwn' });
});
// DWN API endpoints
app.post('/dwn', async (req, res) => {
// Placeholder for DWN protocol implementation
res.json({
status: 'ok',
message: 'DWN protocol endpoint (placeholder)'
});
});
app.get('/dwn', async (req, res) => {
res.json({
status: 'ok',
message: 'DWN query endpoint (placeholder)'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Web5 DWN listening on port ${port}`);
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.68-alpha"
version = "1.7.85-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.69-alpha"
version = "1.7.85-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -128,6 +128,22 @@ impl ApiHandler {
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
));
}
if let Some(handled) =
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
std::path::Path::new("/var/lib/archipelago"),
from,
incoming.from_name.as_deref(),
&val,
)
.await?
{
return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
));
}
}
let safe_from = sanitize_log_string(from);
+48
View File
@@ -189,6 +189,27 @@ impl RpcHandler {
.map(|f| f as u64)
.unwrap_or(0);
let latest = self.metrics_store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest
.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 {
(s.system.mem_used_bytes as f64 / mem_total) * 100.0
} else {
0.0
},
if disk_total > 0.0 {
(s.system.disk_used_bytes as f64 / disk_total) * 100.0
} else {
0.0
},
)
})
.unwrap_or((0.0, 0.0, 0.0));
// Recent alerts from metrics store
let recent_alerts: Vec<serde_json::Value> = self
.metrics_store
@@ -206,10 +227,16 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"ram_mb": total_ram_mb,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"containers": containers,
"container_count": data.package_data.len(),
"running_count": data.package_data.values()
@@ -483,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}
+8 -2
View File
@@ -79,7 +79,8 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.unwrap_or(true);
self.auth_manager
let outcome = self
.auth_manager
.change_password(current_password, new_password, also_change_ssh)
.await?;
@@ -88,7 +89,12 @@ impl RpcHandler {
self.session_store.invalidate_all_except(token).await;
}
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
Ok(serde_json::json!({
"success": true,
"session_rotated": true,
"ssh_updated": outcome.ssh_updated,
"ssh_error": outcome.ssh_error,
}))
}
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
@@ -0,0 +1,963 @@
use super::RpcHandler;
use crate::container::docker_packages;
use crate::data_model::{Notification, NotificationLevel};
use crate::{bitcoin_status, identity, peers};
use anyhow::{Context, Result};
use archipelago_container::ContainerState;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;
use std::path::{Path, PathBuf};
use tokio::fs;
const RELAY_DIR: &str = "bitcoin-relay";
const RELAY_STATE_FILE: &str = "state.json";
const TXRELAY_USER: &str = "txrelay";
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct BitcoinRelayState {
settings: BitcoinRelaySettings,
requests: Vec<BitcoinRelayRequest>,
updated_at: Option<String>,
}
impl Default for BitcoinRelayState {
fn default() -> Self {
Self {
settings: BitcoinRelaySettings::default(),
requests: Vec::new(),
updated_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct BitcoinRelaySettings {
enabled_for_peers: bool,
allow_peer_requests: bool,
allow_http: bool,
allow_https: bool,
allow_tor: bool,
selected_peer_pubkey: Option<String>,
http_endpoint: Option<String>,
https_endpoint: Option<String>,
tor_endpoint: Option<String>,
}
impl Default for BitcoinRelaySettings {
fn default() -> Self {
Self {
enabled_for_peers: false,
allow_peer_requests: false,
allow_http: false,
allow_https: true,
allow_tor: false,
selected_peer_pubkey: None,
http_endpoint: None,
https_endpoint: None,
tor_endpoint: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BitcoinRelayRequest {
id: String,
direction: RelayRequestDirection,
status: RelayRequestStatus,
peer_pubkey: String,
peer_onion: String,
peer_name: Option<String>,
message: Option<String>,
approved_endpoint: Option<String>,
credential_secret_path: Option<String>,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RelayRequestDirection {
Incoming,
Outbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RelayRequestStatus {
Pending,
Approved,
Rejected,
}
#[derive(Debug, Serialize)]
struct TrustedRelayPeer {
pubkey: String,
onion: String,
name: Option<String>,
relay_approved: bool,
}
#[derive(Debug, Clone)]
struct TxRelayCredentials {
username: String,
password: String,
}
impl RpcHandler {
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
let mut state = load_relay_state(&self.config.data_dir).await?;
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
let known_peers = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default();
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
let local_node = local_sync_status().await;
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
Ok(json!({
"settings": state.settings,
"trusted_nodes": trusted_nodes,
"requests": state.requests,
"local_node": local_node,
"credentials": credential_status,
}))
}
pub(super) async fn handle_bitcoin_relay_update_settings(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mut state = load_relay_state(&self.config.data_dir).await?;
let known_peers = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default();
update_bool(
&params,
"enabled_for_peers",
&mut state.settings.enabled_for_peers,
);
update_bool(
&params,
"allow_peer_requests",
&mut state.settings.allow_peer_requests,
);
update_bool(&params, "allow_http", &mut state.settings.allow_http);
update_bool(&params, "allow_https", &mut state.settings.allow_https);
update_bool(&params, "allow_tor", &mut state.settings.allow_tor);
update_endpoint(&params, "http_endpoint", &mut state.settings.http_endpoint)?;
update_endpoint(
&params,
"https_endpoint",
&mut state.settings.https_endpoint,
)?;
update_endpoint(&params, "tor_endpoint", &mut state.settings.tor_endpoint)?;
if state.settings.enabled_for_peers {
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
ensure_txrelay_credentials(&self.config.data_dir).await?;
if !credentials_were_ready {
self.restart_bitcoin_backends_for_txrelay().await;
}
}
if params.get("selected_peer_pubkey").is_some() {
let selected = params
.get("selected_peer_pubkey")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(pubkey) = selected {
if !known_peers.iter().any(|p| p.pubkey == pubkey) {
anyhow::bail!("Selected relay peer is not in trusted nodes");
}
state.settings.selected_peer_pubkey = Some(pubkey.to_string());
} else {
state.settings.selected_peer_pubkey = None;
}
}
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
self.notify(
"Bitcoin relay settings updated",
"Transaction relay sharing preferences were saved.",
)
.await;
self.handle_bitcoin_relay_status().await
}
pub(super) async fn handle_bitcoin_relay_request_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let peer_pubkey = params
.get("peer_pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: peer_pubkey"))?;
let message = params
.get("message")
.and_then(|v| v.as_str())
.map(sanitize_optional_text)
.transpose()?;
let peer = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default()
.into_iter()
.find(|p| p.pubkey == peer_pubkey)
.ok_or_else(|| anyhow::anyhow!("Peer is not in trusted nodes"))?;
let mut state = load_relay_state(&self.config.data_dir).await?;
let existing = state.requests.iter_mut().find(|r| {
r.direction == RelayRequestDirection::Outbound
&& r.peer_pubkey == peer.pubkey
&& r.status == RelayRequestStatus::Pending
});
let request_id = if let Some(req) = existing {
req.message = message.clone();
req.updated_at = now();
req.id.clone()
} else {
let timestamp = now();
let req = BitcoinRelayRequest {
id: uuid::Uuid::new_v4().to_string(),
direction: RelayRequestDirection::Outbound,
status: RelayRequestStatus::Pending,
peer_pubkey: peer.pubkey.clone(),
peer_onion: peer.onion.clone(),
peer_name: peer.name.clone(),
message: message.clone(),
approved_endpoint: None,
credential_secret_path: None,
created_at: timestamp.clone(),
updated_at: timestamp,
};
let id = req.id.clone();
state.requests.push(req);
id
};
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
if let Err(e) = self
.send_relay_peer_message(
&peer,
json!({
"type": "bitcoin_relay_request",
"request_id": request_id,
"message": message,
}),
)
.await
{
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay request");
}
self.notify(
"Bitcoin relay request sent",
"A trusted peer was asked to approve transaction relay access.",
)
.await;
Ok(json!({ "ok": true, "request_id": request_id }))
}
pub(super) async fn handle_bitcoin_relay_approve_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
self.update_relay_request_status(params, RelayRequestStatus::Approved)
.await
}
pub(super) async fn handle_bitcoin_relay_reject_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
self.update_relay_request_status(params, RelayRequestStatus::Rejected)
.await
}
pub(super) async fn handle_bitcoin_relay_create_tor_service(
&self,
) -> Result<serde_json::Value> {
let params = json!({
"name": "bitcoin-rpc",
"local_port": 80,
"remote_port": 80,
});
let created = match self.handle_tor_create_service(Some(params)).await {
Ok(v) => v,
Err(e) if e.to_string().contains("already exists") => {
self.handle_tor_get_onion_address(Some(json!({ "name": "bitcoin-rpc" })))
.await?
}
Err(e) => return Err(e),
};
let onion = created
.get("onion_address")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(onion) = onion {
let mut state = load_relay_state(&self.config.data_dir).await?;
state.settings.allow_tor = true;
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
}
self.notify(
"Bitcoin relay Tor service enabled",
"A Tor endpoint was created for Bitcoin transaction relay access.",
)
.await;
Ok(created)
}
async fn update_relay_request_status(
&self,
params: Option<serde_json::Value>,
status: RelayRequestStatus,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let request_id = params
.get("id")
.or_else(|| params.get("request_id"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
let mut state = load_relay_state(&self.config.data_dir).await?;
let serving_endpoint = if status == RelayRequestStatus::Approved {
preferred_endpoint(&state.settings)
} else {
None
};
let request_direction = state
.requests
.iter()
.find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
.direction;
if status == RelayRequestStatus::Approved
&& request_direction == RelayRequestDirection::Incoming
&& serving_endpoint.is_none()
{
anyhow::bail!(
"Configure an HTTP, HTTPS, or Tor relay endpoint before approving access"
);
}
let credentials = if status == RelayRequestStatus::Approved {
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
if request_direction == RelayRequestDirection::Incoming {
self.restart_bitcoin_backends_for_txrelay().await;
}
Some(credentials)
} else {
None
};
let (peer_pubkey, peer_onion, peer_name, direction) = {
let req = state
.requests
.iter_mut()
.find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
req.status = status;
req.updated_at = now();
if let Some(endpoint) = &serving_endpoint {
req.approved_endpoint = Some(endpoint.clone());
}
(
req.peer_pubkey.clone(),
req.peer_onion.clone(),
req.peer_name.clone(),
req.direction,
)
};
let peer = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default()
.into_iter()
.find(|p| p.pubkey == peer_pubkey);
let peer_name = peer_name.unwrap_or_else(|| peer_onion.clone());
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
if let Some(peer) = peer {
let message_type = match status {
RelayRequestStatus::Approved => "bitcoin_relay_approved",
RelayRequestStatus::Rejected => "bitcoin_relay_rejected",
RelayRequestStatus::Pending => "bitcoin_relay_pending",
};
if let Err(e) = self
.send_relay_peer_message(
&peer,
relay_response_payload(
message_type,
request_id,
direction,
serving_endpoint.as_deref(),
credentials.as_ref(),
),
)
.await
{
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay response");
}
}
let title = match status {
RelayRequestStatus::Approved => "Bitcoin relay request approved",
RelayRequestStatus::Rejected => "Bitcoin relay request rejected",
RelayRequestStatus::Pending => "Bitcoin relay request updated",
};
self.notify(
title,
&format!("Relay access request for {peer_name} was updated."),
)
.await;
Ok(json!({ "ok": true, "request_id": request_id }))
}
async fn send_relay_peer_message(
&self,
peer: &peers::KnownPeer,
mut payload: serde_json::Value,
) -> Result<()> {
let (data, _) = self.state_manager.get_snapshot().await;
let my_pubkey = data.server_info.pubkey.clone();
let my_did = identity::did_key_from_pubkey_hex(&my_pubkey).ok();
let my_onion = docker_packages::read_tor_address("archipelago")
.await
.unwrap_or_default();
payload["from_did"] = my_did.map(serde_json::Value::String).unwrap_or_default();
payload["from_pubkey"] = serde_json::Value::String(my_pubkey.clone());
payload["from_onion"] = serde_json::Value::String(my_onion);
payload["from_name"] = data
.server_info
.name
.clone()
.map(serde_json::Value::String)
.unwrap_or_default();
let to_fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, &peer.onion).await;
let identity_dir = self.config.data_dir.join("identity");
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await
.ok();
crate::node_message::send_to_peer(
&peer.onion,
to_fips_npub.as_deref(),
&my_pubkey,
&payload.to_string(),
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
)
.await
}
async fn notify(&self, title: &str, message: &str) {
let (mut data, _) = self.state_manager.get_snapshot().await;
data.notifications.push(Notification {
id: format!("bitcoin-relay-{}", uuid::Uuid::new_v4()),
level: NotificationLevel::Info,
title: title.to_string(),
message: message.to_string(),
timestamp: now(),
app_id: Some("bitcoin-knots".to_string()),
});
let len = data.notifications.len();
if len > 30 {
data.notifications.drain(0..len - 30);
}
self.state_manager.update_data(data).await;
}
async fn restart_bitcoin_backends_for_txrelay(&self) {
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
return;
};
tokio::spawn(async move {
for app_id in ["bitcoin-knots", "bitcoin-core"] {
let Ok(status) = orchestrator.status(app_id).await else {
continue;
};
if status.state != ContainerState::Running {
continue;
}
match orchestrator.restart(app_id).await {
Ok(()) => tracing::info!(
app_id,
"Restarted Bitcoin backend to load txrelay RPC credentials"
),
Err(e) => tracing::warn!(
app_id,
error = %e,
"Failed to restart Bitcoin backend after txrelay credential update"
),
}
}
});
}
}
pub(crate) async fn record_incoming_relay_message(
data_dir: &Path,
from_pubkey: &str,
from_name: Option<&str>,
payload: &serde_json::Value,
) -> Result<Option<&'static str>> {
let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"bitcoin_relay_request" => {
let from_onion = payload
.get("from_onion")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let message = payload
.get("message")
.and_then(|v| v.as_str())
.map(sanitize_optional_text)
.transpose()?;
let remote_request_id = payload
.get("request_id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let mut state = load_relay_state(data_dir).await?;
if !state.settings.allow_peer_requests {
return Ok(Some("bitcoin_relay_request_disabled"));
}
if !state.requests.iter().any(|r| {
r.direction == RelayRequestDirection::Incoming
&& r.peer_pubkey == from_pubkey
&& r.status == RelayRequestStatus::Pending
}) {
let timestamp = now();
state.requests.push(BitcoinRelayRequest {
id: if remote_request_id.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
remote_request_id.to_string()
},
direction: RelayRequestDirection::Incoming,
status: RelayRequestStatus::Pending,
peer_pubkey: from_pubkey.to_string(),
peer_onion: from_onion,
peer_name: from_name.map(String::from),
message,
approved_endpoint: None,
credential_secret_path: None,
created_at: timestamp.clone(),
updated_at: timestamp,
});
state.updated_at = Some(now());
save_relay_state(data_dir, &state).await?;
}
Ok(Some("bitcoin_relay_request"))
}
"bitcoin_relay_approved" | "bitcoin_relay_rejected" => {
let request_id = payload.get("request_id").and_then(|v| v.as_str());
let mut state = load_relay_state(data_dir).await?;
let status = if msg_type == "bitcoin_relay_approved" {
RelayRequestStatus::Approved
} else {
RelayRequestStatus::Rejected
};
let approved_access = if status == RelayRequestStatus::Approved {
save_peer_relay_access(data_dir, from_pubkey, payload).await?
} else {
None
};
if let Some(req) = state.requests.iter_mut().find(|r| {
r.direction == RelayRequestDirection::Outbound
&& r.peer_pubkey == from_pubkey
&& request_id.map(|id| id == r.id).unwrap_or(true)
}) {
req.status = status;
req.updated_at = now();
if let Some((endpoint, secret_path)) = approved_access {
req.approved_endpoint = Some(endpoint);
req.credential_secret_path = Some(secret_path);
}
state.updated_at = Some(now());
save_relay_state(data_dir, &state).await?;
}
Ok(Some(if msg_type == "bitcoin_relay_approved" {
"bitcoin_relay_approved"
} else {
"bitcoin_relay_rejected"
}))
}
_ => Ok(None),
}
}
fn trusted_relay_peers(
known_peers: &[peers::KnownPeer],
state: &BitcoinRelayState,
) -> Vec<TrustedRelayPeer> {
known_peers
.iter()
.map(|peer| TrustedRelayPeer {
pubkey: peer.pubkey.clone(),
onion: peer.onion.clone(),
name: peer.name.clone(),
relay_approved: state.requests.iter().any(|req| {
req.peer_pubkey == peer.pubkey && req.status == RelayRequestStatus::Approved
}),
})
.collect()
}
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
let credentials_available = txrelay_credentials_available(data_dir).await;
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password_available = fs::metadata(&password_path).await.is_ok();
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
json!({
"username": TXRELAY_USER,
"available": credentials_available,
"password_available": password_available,
"rpcauth_available": rpcauth_available,
"client_env_available": client_env_available,
"client_env_path": client_env_path.display().to_string(),
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
})
}
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
fs::metadata(&password_path).await.is_ok()
&& fs::metadata(&rpcauth_path).await.is_ok()
&& fs::metadata(&client_env_path).await.is_ok()
}
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password = match read_trimmed(&password_path).await {
Some(value) => value,
None => {
let generated = generate_random_password();
write_secret_file(&password_path, &generated).await?;
generated
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
}
};
let client_env = format!(
"BITCOIN_RPC_TXRELAY_USER={}\nBITCOIN_RPC_TXRELAY_PASSWORD={}\nBITCOIN_RPC_TXRELAY_RPCAUTH={}\n",
TXRELAY_USER, password, rpcauth
);
write_secret_file(&client_env_path, &client_env).await?;
Ok(TxRelayCredentials {
username: TXRELAY_USER.to_string(),
password,
})
}
fn txrelay_secret_paths(data_dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
let secrets_dir = data_dir.join("secrets");
(
secrets_dir.join(TXRELAY_PASSWORD_FILE),
secrets_dir.join(TXRELAY_RPCAUTH_FILE),
secrets_dir.join(TXRELAY_CLIENT_ENV_FILE),
)
}
async fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn write_secret_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(path, contents).await?;
set_private_permissions(path).await;
Ok(())
}
async fn set_private_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
}
}
fn generate_random_password() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
BASE64.encode(bytes)
}
fn generate_rpcauth(username: &str, password: &str) -> String {
let mut salt_bytes = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt_bytes);
let salt_hex = hex::encode(salt_bytes);
let mut mac =
Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()).expect("HMAC accepts any key length");
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
format!("{username}:{salt_hex}${hash_hex}")
}
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {
return Some(endpoint);
}
}
if settings.allow_tor {
if let Some(endpoint) = settings.tor_endpoint.clone() {
return Some(endpoint);
}
}
if settings.allow_http {
if let Some(endpoint) = settings.http_endpoint.clone() {
return Some(endpoint);
}
}
settings
.https_endpoint
.clone()
.or_else(|| settings.tor_endpoint.clone())
.or_else(|| settings.http_endpoint.clone())
}
fn relay_response_payload(
message_type: &str,
request_id: &str,
request_direction: RelayRequestDirection,
endpoint: Option<&str>,
credentials: Option<&TxRelayCredentials>,
) -> serde_json::Value {
let mut payload = json!({
"type": message_type,
"request_id": request_id,
});
if message_type == "bitcoin_relay_approved"
&& request_direction == RelayRequestDirection::Incoming
{
if let (Some(endpoint), Some(credentials)) = (endpoint, credentials) {
payload["relay_access"] = json!({
"endpoint": endpoint,
"username": &credentials.username,
"password": &credentials.password,
});
}
}
payload
}
async fn save_peer_relay_access(
data_dir: &Path,
from_pubkey: &str,
payload: &serde_json::Value,
) -> Result<Option<(String, String)>> {
let Some(access) = payload.get("relay_access") else {
return Ok(None);
};
let endpoint = access
.get("endpoint")
.and_then(|v| v.as_str())
.map(validate_endpoint)
.transpose()?;
let username = access.get("username").and_then(|v| v.as_str());
let password = access.get("password").and_then(|v| v.as_str());
let (Some(endpoint), Some(username), Some(password)) = (endpoint, username, password) else {
return Ok(None);
};
validate_env_value(username)?;
validate_env_value(password)?;
let secret_path = data_dir.join("secrets").join(format!(
"bitcoin-relay-peer-{}.env",
safe_pubkey_fragment(from_pubkey)
));
let contents = format!(
"BITCOIN_RELAY_PEER_PUBKEY={}\nBITCOIN_RELAY_ENDPOINT={}\nBITCOIN_RELAY_USERNAME={}\nBITCOIN_RELAY_PASSWORD={}\n",
from_pubkey, endpoint, username, password
);
write_secret_file(&secret_path, &contents).await?;
Ok(Some((endpoint, secret_path.display().to_string())))
}
fn validate_env_value(value: &str) -> Result<()> {
if value.is_empty() || value.len() > 1024 || value.contains('\n') || value.contains('\r') {
anyhow::bail!("Invalid relay credential value");
}
Ok(())
}
fn safe_pubkey_fragment(pubkey: &str) -> String {
let fragment = pubkey
.chars()
.filter(|c| c.is_ascii_hexdigit())
.take(24)
.collect::<String>();
if fragment.is_empty() {
"unknown".to_string()
} else {
fragment
}
}
async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
if state.settings.tor_endpoint.is_some() {
return;
}
if let Some(onion) = docker_packages::read_tor_address("bitcoin-rpc").await {
let onion = onion.trim().trim_end_matches('/').to_string();
if !onion.is_empty() {
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
let _ = save_relay_state(data_dir, state).await;
}
}
}
async fn local_sync_status() -> serde_json::Value {
let status = bitcoin_status::get_bitcoin_status().await;
let blockchain = status.blockchain_info.as_ref();
let blocks = blockchain
.and_then(|v| v.get("blocks"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let headers = blockchain
.and_then(|v| v.get("headers"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let initial_block_download = blockchain
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let synced =
status.ok && headers > 0 && blocks >= headers.saturating_sub(1) && !initial_block_download;
json!({
"synced": synced,
"blocks": blocks,
"headers": headers,
"chain": blockchain
.and_then(|v| v.get("chain"))
.and_then(|v| v.as_str())
.unwrap_or("unknown"),
"status_ok": status.ok,
"status_stale": status.stale,
"error": status.error,
})
}
async fn load_relay_state(data_dir: &Path) -> Result<BitcoinRelayState> {
let path = state_path(data_dir);
if !path.exists() {
return Ok(BitcoinRelayState::default());
}
let content = fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read {}", path.display()))?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
async fn save_relay_state(data_dir: &Path, state: &BitcoinRelayState) -> Result<()> {
let dir = data_dir.join(RELAY_DIR);
fs::create_dir_all(&dir).await?;
let content = serde_json::to_string_pretty(state)?;
fs::write(dir.join(RELAY_STATE_FILE), content).await?;
Ok(())
}
fn state_path(data_dir: &Path) -> PathBuf {
data_dir.join(RELAY_DIR).join(RELAY_STATE_FILE)
}
fn update_bool(params: &serde_json::Value, key: &str, target: &mut bool) {
if let Some(value) = params.get(key).and_then(|v| v.as_bool()) {
*target = value;
}
}
fn update_endpoint(
params: &serde_json::Value,
key: &str,
target: &mut Option<String>,
) -> Result<()> {
if !params.get(key).is_some() {
return Ok(());
}
let endpoint = params
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
*target = endpoint.map(validate_endpoint).transpose()?;
Ok(())
}
fn validate_endpoint(endpoint: &str) -> Result<String> {
if endpoint.len() > 512 || endpoint.contains('\n') || endpoint.contains('\r') {
anyhow::bail!("Invalid endpoint");
}
let lower = endpoint.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
anyhow::bail!("Endpoint must start with http:// or https://");
}
Ok(endpoint.to_string())
}
fn sanitize_optional_text(value: &str) -> Result<String> {
let value = value.trim();
if value.len() > 500 || value.contains('\0') {
anyhow::bail!("Invalid message");
}
Ok(value.to_string())
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
+271 -51
View File
@@ -4,8 +4,9 @@ use super::RpcHandler;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(5);
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(5);
const ORCHESTRATOR_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
impl RpcHandler {
pub(super) async fn handle_container_install(
@@ -171,46 +172,69 @@ impl RpcHandler {
// between "installed" and "not-installed" in the UI.
let (data, _) = self.state_manager.get_snapshot().await;
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
let containers: Vec<serde_json::Value> = data
.package_data
.iter()
.map(|(id, pkg)| {
// Keep this mapping in sync with the UI's
// ContainerStatus.state union in
// neode-ui/src/api/container-client.ts. The UI maps
// transitional variants to single-button labels
// (Stopping… / Starting… / Restarting…).
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "starting",
crate::data_model::PackageState::Stopping => "stopping",
crate::data_model::PackageState::Restarting => "restarting",
crate::data_model::PackageState::Installing => "installing",
crate::data_model::PackageState::Installed => "installed",
crate::data_model::PackageState::Updating => "updating",
crate::data_model::PackageState::Removing => "removing",
crate::data_model::PackageState::CreatingBackup => "creating-backup",
crate::data_model::PackageState::RestoringBackup => "restoring-backup",
crate::data_model::PackageState::BackingUp => "backing-up",
};
let lan = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
})
})
.collect();
let mut containers = Vec::with_capacity(data.package_data.len());
for (id, pkg) in &data.package_data {
// Keep this mapping in sync with the UI's
// ContainerStatus.state union in
// neode-ui/src/api/container-client.ts. The UI maps
// transitional variants to single-button labels
// (Stopping… / Starting… / Restarting…).
let mut state = match &pkg.state {
crate::data_model::PackageState::Running => "running".to_string(),
crate::data_model::PackageState::Stopped => "stopped".to_string(),
crate::data_model::PackageState::Exited => "exited".to_string(),
crate::data_model::PackageState::Starting => "starting".to_string(),
crate::data_model::PackageState::Stopping => "stopping".to_string(),
crate::data_model::PackageState::Restarting => "restarting".to_string(),
crate::data_model::PackageState::Installing => "installing".to_string(),
crate::data_model::PackageState::Installed => "installed".to_string(),
crate::data_model::PackageState::Updating => "updating".to_string(),
crate::data_model::PackageState::Removing => "removing".to_string(),
crate::data_model::PackageState::CreatingBackup => {
"creating-backup".to_string()
}
crate::data_model::PackageState::RestoringBackup => {
"restoring-backup".to_string()
}
crate::data_model::PackageState::BackingUp => "backing-up".to_string(),
};
// Scanner backoff preserves cached package_data. Refresh stable
// states so callers do not see stale `running`/`exited` after
// health-monitor recovery or Quadlet --rm container removal.
if state == "running" && requires_launch_port_for_health(id) {
if !self.cached_reachable_health(id).await?.is_some() {
state = live_state_for_app(id)
.await
.unwrap_or("starting".to_string());
}
} else if should_refresh_cached_state(&state) {
if launch_port_reachable(id).await {
state = "running".to_string();
} else {
if let Some(live) = live_state_for_app(id).await {
state = live;
} else if quadlet_service_active(id).await {
state = "starting".to_string();
}
}
}
let lan = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
containers.push(serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
}));
}
return Ok(serde_json::json!(containers));
}
@@ -383,15 +407,33 @@ impl RpcHandler {
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(serde_json::json!({ app_id: health }));
}
if let Some(health) = self.cached_state_health(app_id).await {
return Ok(serde_json::json!({ app_id: health }));
}
if requires_launch_port_for_health(app_id) {
return Ok(serde_json::json!({ app_id: "starting" }));
}
if let Some(health) = self.stack_health(app_id).await? {
return Ok(serde_json::json!({ app_id: health }));
}
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await {
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
Err(e) => last_err = Some(e),
match tokio::time::timeout(
ORCHESTRATOR_HEALTH_TIMEOUT,
orchestrator.health(&candidate),
)
.await
{
Ok(Ok(health)) => return Ok(serde_json::json!({ app_id: health })),
Ok(Err(e)) => last_err = Some(e),
Err(_) => {}
}
}
for name in status_container_name_candidates(app_id) {
@@ -424,14 +466,19 @@ impl RpcHandler {
.and_then(|s| s.strip_suffix("-dev"))
.or_else(|| container.name.strip_prefix("archy-"))
.unwrap_or(container.name.as_str());
match orchestrator.health(app_id_candidate).await {
Ok(health) => {
match tokio::time::timeout(
ORCHESTRATOR_HEALTH_TIMEOUT,
orchestrator.health(app_id_candidate),
)
.await
{
Ok(Ok(health)) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String(health),
);
}
Err(_) => {
Ok(Err(_)) | Err(_) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String("unknown".to_string()),
@@ -443,6 +490,65 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
async fn cached_state_health(&self, app_id: &str) -> Option<&'static str> {
let (data, _) = self.state_manager.get_snapshot().await;
let Some(pkg) = data.package_data.get(app_id) else {
if data.server_info.status_info.containers_scanned {
return Some("stopped");
}
return None;
};
match pkg.state {
crate::data_model::PackageState::Running => None,
crate::data_model::PackageState::Installing
| crate::data_model::PackageState::Installed
| crate::data_model::PackageState::Starting => Some("starting"),
crate::data_model::PackageState::Stopping
| crate::data_model::PackageState::Stopped
| crate::data_model::PackageState::Exited => Some("stopped"),
crate::data_model::PackageState::Removing => Some("removing"),
crate::data_model::PackageState::Restarting
| crate::data_model::PackageState::Updating
| crate::data_model::PackageState::CreatingBackup
| crate::data_model::PackageState::RestoringBackup
| crate::data_model::PackageState::BackingUp => Some("starting"),
}
}
async fn cached_reachable_health(&self, app_id: &str) -> Result<Option<String>> {
let (data, _) = self.state_manager.get_snapshot().await;
let pkg = data.package_data.get(app_id);
if matches!(
pkg.map(|pkg| &pkg.state),
Some(crate::data_model::PackageState::Removing)
) {
return Ok(None);
}
let url = pkg
.and_then(|pkg| pkg.installed.as_ref())
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref())
.map(ToOwned::to_owned)
.or_else(|| health_probe_url_for_app(app_id));
let Some(url) = url else {
return Ok(None);
};
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(http_launch_url_reachable(&url)
.await
.then(|| "healthy".to_string()));
}
let Some(port) = port_from_url(&url) else {
return Ok(None);
};
Ok(launch_port_reachable_by_port(port)
.await
.then(|| "healthy".to_string()))
}
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
let Some(members) = stack_health_members(app_id) else {
return Ok(None);
@@ -469,8 +575,14 @@ impl RpcHandler {
}
if saw_unknown {
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(Some(health));
}
Ok(Some("unknown".to_string()))
} else if saw_starting {
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(Some(health));
}
Ok(Some("starting".to_string()))
} else {
Ok(Some("healthy".to_string()))
@@ -482,7 +594,9 @@ async fn member_health(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
app_id: &str,
) -> Result<String> {
if let Ok(health) = orchestrator.health(app_id).await {
if let Ok(Ok(health)) =
tokio::time::timeout(ORCHESTRATOR_HEALTH_TIMEOUT, orchestrator.health(app_id)).await
{
return Ok(health);
}
for name in status_container_name_candidates(app_id) {
@@ -508,10 +622,8 @@ fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
]),
"fedimint" => Some(&["fedimint"]),
_ => None,
}
}
@@ -583,6 +695,114 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
out
}
fn should_refresh_cached_state(state: &str) -> bool {
matches!(state, "exited" | "stopped" | "stopping")
}
async fn live_state_for_app(app_id: &str) -> Option<String> {
for name in status_container_name_candidates(app_id) {
if let Some(live) = inspect_container_state_value(&name).await {
if let Some(live_state) = live.get("state").and_then(|v| v.as_str()) {
return Some(live_state.to_string());
}
}
}
None
}
async fn quadlet_service_active(app_id: &str) -> bool {
for name in status_container_name_candidates(app_id) {
let service = format!("{name}.service");
let mut cmd = tokio::process::Command::new("systemctl");
cmd.args(["--user", "is-active", "--quiet", &service]);
cmd.kill_on_drop(true);
if matches!(
tokio::time::timeout(Duration::from_secs(2), cmd.status()).await,
Ok(Ok(status)) if status.success()
) {
return true;
}
}
false
}
fn health_probe_url_for_app(app_id: &str) -> Option<String> {
let port = match app_id {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,
"gitea" => 3001,
"grafana" => 3000,
"homeassistant" | "home-assistant" => 8123,
"immich" | "immich_server" => 2283,
"indeedhub" => 7778,
"jellyfin" => 8096,
"lnd" | "lnd-ui" => 18083,
"mempool" | "mempool-web" => 4080,
"nginx-proxy-manager" => 8081,
"ollama" => 11434,
"photoprism" => 2342,
"portainer" => 9000,
"searxng" => 8888,
"tailscale" => 8240,
"uptime-kuma" => 3002,
"vaultwarden" => 8082,
_ => return None,
};
Some(format!("http://localhost:{port}"))
}
fn requires_launch_port_for_health(app_id: &str) -> bool {
matches!(app_id, "fedimint" | "fedimintd" | "fedimint-gateway")
}
async fn launch_port_reachable(app_id: &str) -> bool {
let Some(port) = health_probe_url_for_app(app_id).and_then(|url| port_from_url(&url)) else {
return false;
};
launch_port_reachable_by_port(port).await
}
async fn launch_port_reachable_by_port(port: u16) -> bool {
matches!(
tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
async fn http_launch_url_reachable(url: &str) -> bool {
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
.build()
else {
return false;
};
match client.get(url).send().await {
Ok(response) => {
let status = response.status();
status.is_success() || status.is_redirection()
}
Err(_) => false,
}
}
fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
let port = after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
port.parse::<u16>().ok()
}
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
if let Some(v) = ps_container_state_value(name).await {
return Some(v);
@@ -55,6 +55,7 @@ impl RpcHandler {
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
@@ -97,6 +98,20 @@ impl RpcHandler {
// Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"bitcoin.relay-status" => self.handle_bitcoin_relay_status().await,
"bitcoin.relay-update-settings" => {
self.handle_bitcoin_relay_update_settings(params).await
}
"bitcoin.relay-request-peer" => self.handle_bitcoin_relay_request_peer(params).await,
"bitcoin.relay-approve-request" => {
self.handle_bitcoin_relay_approve_request(params).await
}
"bitcoin.relay-reject-request" => {
self.handle_bitcoin_relay_reject_request(params).await
}
"bitcoin.relay-create-tor-service" => {
self.handle_bitcoin_relay_create_tor_service().await
}
"bitcoin.init-wallet-from-seed" => {
self.handle_bitcoin_init_wallet_from_seed(params).await
}
+45 -22
View File
@@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
@@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
.output()
.await;
let mut args = vec![
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args([
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
.args(args)
.output()
.await
.context("Failed to run nmcli wifi profile create")?;
+53 -1
View File
@@ -13,20 +13,40 @@ impl RpcHandler {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"LND could not generate a Bitcoin address ({}): {}",
status,
message
);
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("")
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -543,3 +563,35 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
#[cfg(test)]
mod tests {
use super::lnd_error_message;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
}
+2
View File
@@ -2,6 +2,7 @@ mod analytics;
mod auth;
mod backup_rpc;
mod bitcoin;
pub(crate) mod bitcoin_relay;
mod container;
mod content;
mod credentials;
@@ -302,6 +303,7 @@ impl RpcHandler {
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
+18 -32
View File
@@ -3,7 +3,7 @@ use crate::port_allocator::PortAllocator;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(15);
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(60);
fn is_platform_managed_app(app_id: &str) -> bool {
matches!(
@@ -219,7 +219,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
],
// VPN/mesh daemons need TUN + NET_ADMIN.
// Note: --device=/dev/net/tun is added separately in install.rs
"nostr-vpn" | "fips" | "netbird" => vec![
"nostr-vpn" | "fips" => vec![
"--cap-add=NET_ADMIN".to_string(),
"--cap-add=NET_RAW".to_string(),
],
@@ -312,11 +312,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
@@ -329,7 +324,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"3",
),
"nostr-vpn" => ("nvpn status || exit 1", "30s", "3"),
"netbird" => ("netbird status || exit 1", "30s", "3"),
"fips" => ("fipsctl status || exit 1", "30s", "3"),
_ => return vec![],
};
@@ -361,10 +355,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -385,12 +379,11 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
"nostr-vpn" => "256m",
"netbird" => "256m",
"netbird" => "1g",
"fips" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
@@ -496,6 +489,11 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
"netbird" => vec![
"netbird".into(),
"netbird-dashboard".into(),
"netbird-server".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -584,6 +582,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
format!("{}/penpot-assets", base),
format!("{}/penpot-postgres", base),
],
"netbird" => vec![format!("{}/netbird", base)],
_ => vec![format!("{}/{}", base, package_id)],
}
}
@@ -784,11 +783,9 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -961,6 +958,7 @@ pub(super) async fn get_app_config(
vec![
"/var/lib/archipelago/portainer:/data".to_string(),
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
"/var/lib/archipelago/portainer/compose:/data/compose".to_string(),
],
vec![],
None,
@@ -990,7 +988,7 @@ pub(super) async fn get_app_config(
Some(vec![
"sh".to_string(),
"-c".to_string(),
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
]),
),
"fedimint" => (
@@ -1123,18 +1121,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(
@@ -288,6 +288,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -389,6 +390,14 @@ mod tests {
);
}
#[test]
fn netbird_start_order_starts_server_before_dashboard() {
assert_eq!(
startup_order("netbird"),
&["netbird-server", "netbird-dashboard", "netbird"]
);
}
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
for package_id in [
+297 -73
View File
@@ -13,11 +13,12 @@ use crate::api::rpc::RpcHandler;
use crate::data_model::InstallPhase;
use crate::update::host_sudo;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
const IMAGE_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Append a timestamped line to the persistent install log.
pub(in crate::api::rpc) async fn install_log(msg: &str) {
@@ -34,6 +35,36 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
}
}
async fn local_podman_image_exists(image: &str) -> Result<bool> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["image", "inspect", image]);
cmd.kill_on_drop(true);
let output = timeout(IMAGE_INSPECT_TIMEOUT, cmd.output())
.await
.with_context(|| {
format!(
"podman image inspect {} timed out after {}s",
image,
IMAGE_INSPECT_TIMEOUT.as_secs()
)
})?
.with_context(|| format!("Failed to execute podman image inspect {}", image))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => Err(anyhow::anyhow!(
"podman image inspect {} exited with {}: {}",
image,
code,
String::from_utf8_lossy(&output.stderr).trim()
)),
None => Err(anyhow::anyhow!(
"podman image inspect {} terminated by signal",
image
)),
}
}
pub(super) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
@@ -241,7 +272,9 @@ impl RpcHandler {
if package_id == "indeedhub" {
return self.install_indeedhub_stack().await;
}
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
@@ -441,6 +474,7 @@ impl RpcHandler {
Ok(container_name) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
ensure_host_port_listener(package_id, &container_name, &[]).await?;
crate::api::rpc::package::runtime::reconcile_companions_for(package_id)
.await;
install_log(&format!(
@@ -552,7 +586,6 @@ impl RpcHandler {
"uptime-kuma"
| "gitea"
| "tailscale"
| "netbird"
| "vaultwarden"
| "homeassistant"
| "home-assistant"
@@ -627,10 +660,6 @@ impl RpcHandler {
run_args.push("--tmpfs=/tmp:rw,exec,size=256m");
}
if package_id == "netbird" {
run_args.push("--device=/dev/net/tun:/dev/net/tun");
}
// Create data directories (mkdir only — chown happens AFTER config files are written)
for volume in &volumes {
if let Some(host_path) = volume.split(':').next() {
@@ -651,10 +680,6 @@ impl RpcHandler {
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
}
if package_id == "portainer" {
ensure_user_podman_socket().await?;
}
// Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng";
@@ -747,16 +772,10 @@ impl RpcHandler {
.await;
debug!("Running container with args: {:?}", run_args);
// Build command with optional custom command/args
let mut cmd = tokio::process::Command::new("podman");
cmd.args(&run_args);
if let Some(custom_cmd) = custom_command {
cmd.arg(custom_cmd);
} else if let Some(args) = custom_args {
cmd.args(args);
}
let mut run_output = cmd.output().await.context("Failed to run container")?;
let command_tail = install_command_tail(custom_command.as_deref(), custom_args.as_ref());
let mut run_output = podman_run_for_install(package_id, &run_args, &command_tail)
.await
.context("Failed to run container")?;
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
@@ -765,7 +784,9 @@ impl RpcHandler {
.args(["rm", "-f", container_name])
.output()
.await;
run_output = cmd.output().await.context("Failed to rerun container")?;
run_output = podman_run_for_install(package_id, &run_args, &command_tail)
.await
.context("Failed to rerun container")?;
}
}
@@ -921,12 +942,7 @@ impl RpcHandler {
let is_local_image = docker_image.starts_with("localhost/");
let has_local_fallback = if !is_local_image {
let local_tag = format!("localhost/{}:latest", package_id);
let check = tokio::process::Command::new("podman")
.args(["images", "-q", &local_tag])
.output()
.await
.ok();
check.is_some_and(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
local_podman_image_exists(&local_tag).await.unwrap_or(false)
} else {
false
};
@@ -941,14 +957,9 @@ impl RpcHandler {
);
} else {
// Local image — verify it exists
let images_output = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
if !local_podman_image_exists(docker_image)
.await
.context("Failed to check local image")?;
if String::from_utf8_lossy(&images_output.stdout)
.trim()
.is_empty()
.context("Failed to check local image")?
{
return Err(anyhow::anyhow!(
"Local image {} not found. Build the image first \
@@ -1138,12 +1149,10 @@ impl RpcHandler {
}
// Verify image exists locally after pull.
let verify = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
if !local_podman_image_exists(docker_image)
.await
.context("Failed to verify pulled image")?;
if String::from_utf8_lossy(&verify.stdout).trim().is_empty() {
.context("Failed to verify pulled image")?
{
return Err(anyhow::anyhow!(
"Image {} not found locally after pull",
docker_image
@@ -1277,11 +1286,13 @@ impl RpcHandler {
// set `prune=N` in bitcoin.conf themselves after install.
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only no plaintext password in config or CLI\n\
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
{}\n\
server=1\n\
rpcallowip=0.0.0.0/0\n\
listen=1\n\
rpcthreads=16\n\
rpcworkqueue=256\n\
printtoconsole=1\n",
rpcauth_line
);
@@ -1858,6 +1869,47 @@ autopilot.active=false\n",
Ok(serde_json::json!({ "token": token }))
}
pub(in crate::api::rpc) async fn handle_package_credentials(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let app_id = params
.as_ref()
.and_then(|p| p.get("app_id"))
.and_then(|v| v.as_str())
.unwrap_or_default();
super::validation::validate_app_id(app_id)?;
if app_id == "filebrowser" {
let password =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
.await
.map(|p| p.trim().to_string())
.unwrap_or_else(|_| "admin".to_string());
return Ok(serde_json::json!({
"title": "File Browser credentials",
"description": "Use these credentials when File Browser asks you to sign in.",
"credentials": [
{ "label": "Username", "value": "admin" },
{ "label": "Password", "value": password, "sensitive": true }
]
}));
}
if app_id == "photoprism" {
return Ok(serde_json::json!({
"title": "PhotoPrism credentials",
"description": "Use these credentials when PhotoPrism asks you to sign in.",
"credentials": [
{ "label": "Username", "value": "admin" },
{ "label": "Password", "value": "archipelago", "sensitive": true }
]
}));
}
Ok(serde_json::json!({ "credentials": [] }))
}
}
async fn cleanup_stale_package_ports(package_id: &str) {
@@ -1877,10 +1929,128 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"portainer" => cleanup_stale_pasta_port("9000").await,
_ => {}
}
}
fn install_command_tail(
custom_cmd: Option<&str>,
custom_args: Option<&Vec<String>>,
) -> Vec<String> {
if let Some(cmd) = custom_cmd {
vec![cmd.to_string()]
} else if let Some(args) = custom_args {
args.clone()
} else {
Vec::new()
}
}
async fn podman_run_for_install(
package_id: &str,
run_args: &[&str],
command_tail: &[String],
) -> Result<std::process::Output> {
if should_scope_podman_run(package_id) {
match podman_create_then_scoped_start(package_id, run_args, command_tail).await {
Ok(output) => return Ok(output),
Err(err) => {
tracing::warn!(package_id, error = %err, "scoped podman create/start failed; falling back to direct podman run");
}
}
}
let mut cmd = tokio::process::Command::new("podman");
cmd.args(run_args);
cmd.args(command_tail);
cmd.output().await.context("Failed to run podman")
}
async fn podman_create_then_scoped_start(
package_id: &str,
run_args: &[&str],
command_tail: &[String],
) -> Result<std::process::Output> {
let container_name = run_args
.windows(2)
.find_map(|pair| (pair[0] == "--name").then_some(pair[1]))
.unwrap_or(package_id);
let mut create_args = Vec::with_capacity(run_args.len() + command_tail.len());
for (idx, arg) in run_args.iter().enumerate() {
if idx == 0 && *arg == "run" {
create_args.push("create".to_string());
} else if *arg != "-d" {
create_args.push((*arg).to_string());
}
}
create_args.extend(command_tail.iter().cloned());
let mut create = tokio::process::Command::new("podman");
create.args(&create_args);
let create_output = create
.output()
.await
.context("Failed to run podman create")?;
if !create_output.status.success() {
return Ok(create_output);
}
let mut scoped_start = tokio::process::Command::new("systemd-run");
scoped_start.args([
"--user",
"--scope",
"--quiet",
"--collect",
"podman",
"start",
container_name,
]);
match scoped_start.output().await {
Ok(output) if output.status.success() => Ok(create_output),
Ok(output) => {
tracing::warn!(
package_id,
container = container_name,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"scoped podman start after create failed; trying direct podman start"
);
let mut direct_start = tokio::process::Command::new("podman");
direct_start.args(["start", container_name]);
let direct_output = direct_start
.output()
.await
.context("Failed to run fallback podman start")?;
if direct_output.status.success() {
Ok(create_output)
} else {
Ok(direct_output)
}
}
Err(err) => Err(err).context("Failed to run scoped podman start"),
}
}
fn should_scope_podman_run(package_id: &str) -> bool {
matches!(
package_id,
"botfights"
| "filebrowser"
| "gitea"
| "grafana"
| "homeassistant"
| "home-assistant"
| "jellyfin"
| "nginx-proxy-manager"
| "nostr-rs-relay"
| "photoprism"
| "portainer"
| "searxng"
| "uptime-kuma"
| "vaultwarden"
)
}
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
if stderr.contains("name is already in use") || stderr.contains("name \"") {
return true;
@@ -1931,6 +2101,12 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
cleanup_stale_pasta_port("8085").await;
true
}
"portainer"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("9000").await;
true
}
_ => false,
}
}
@@ -1989,7 +2165,7 @@ async fn ensure_host_port_listener(
return Ok(());
};
if wait_for_host_port(port, 10).await {
if wait_for_host_port(package_id, port, 10).await {
return Ok(());
}
@@ -2015,7 +2191,7 @@ async fn ensure_host_port_listener(
));
}
if wait_for_host_port(port, 60).await {
if wait_for_host_port(package_id, port, 60).await {
install_log(&format!(
"INSTALL REPAIR OK: {} — host port {} is listening after restart",
package_id, port
@@ -2047,31 +2223,6 @@ fn published_host_port(container_name: &str) -> Option<u16> {
})
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
let status = tokio::process::Command::new("systemctl")
.args(["--user", "restart", "podman.socket"])
.status()
.await
.context("spawn systemctl --user restart podman.socket")?;
if !status.success() {
anyhow::bail!("systemctl --user restart podman.socket exited {status}");
}
for _ in 0..20 {
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
anyhow::bail!("podman socket {socket_path} did not appear after restart")
}
fn required_host_port(package_id: &str) -> Option<u16> {
match package_id {
"grafana" => Some(3000),
@@ -2081,17 +2232,21 @@ fn required_host_port(package_id: &str) -> Option<u16> {
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(8081),
"portainer" => Some(9000),
_ => None,
}
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
async fn wait_for_host_port(package_id: &str, port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
let ready = match package_id {
"uptime-kuma" => http_host_port_ready(port, "/").await,
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok(),
};
if ready {
return true;
}
@@ -2103,6 +2258,36 @@ async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
}
}
async fn http_host_port_ready(port: u16, path: &str) -> bool {
let Ok(Ok(mut stream)) = tokio::time::timeout(
Duration::from_secs(3),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
else {
return false;
};
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).await.is_err() {
return false;
}
let mut buf = [0u8; 128];
let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)).await
else {
return false;
};
if n == 0 {
return false;
}
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.1 2")
|| head.starts_with("HTTP/1.1 3")
|| head.starts_with("HTTP/1.0 2")
|| head.starts_with("HTTP/1.0 3")
}
/// Resolve the host gateway IP for --add-host flag.
/// Resolve the default gateway IP from the routing table for --add-host flag.
/// Explicit IP avoids issues with "host-gateway" in rootless Podman.
@@ -2198,6 +2383,18 @@ set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
tmp=$(mktemp)
awk -F= '
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
if (seen[$1]++) next
}
{ print }
' "$conf" > "$tmp"
if ! cmp -s "$conf" "$tmp"; then
cat "$tmp" > "$conf"
changed=1
fi
rm -f "$tmp"
ensure_line() {
line="$1"
key="${line%%=*}"
@@ -2209,6 +2406,8 @@ ensure_line() {
ensure_line server=1
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
ensure_line rpcthreads=16
ensure_line rpcworkqueue=256
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
@@ -2235,6 +2434,7 @@ fn should_try_orchestrator_install(package_id: &str, orchestrator_available: boo
fn orchestrator_install_app_id(package_id: &str) -> &str {
match package_id {
"electrs" | "mempool-electrs" => "electrumx",
"home-assistant" => "homeassistant",
_ => package_id,
}
}
@@ -2262,6 +2462,16 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
| "archy-btcpay-db"
| "archy-nbxplorer"
| "btcpay-server"
| "homeassistant"
| "home-assistant"
| "nextcloud"
| "vaultwarden"
| "jellyfin"
| "photoprism"
| "uptime-kuma"
| "gitea"
| "portainer"
| "meshtastic"
)
}
@@ -2299,6 +2509,16 @@ mod tests {
"archy-btcpay-db",
"archy-nbxplorer",
"btcpay-server",
"homeassistant",
"home-assistant",
"nextcloud",
"vaultwarden",
"jellyfin",
"photoprism",
"uptime-kuma",
"gitea",
"portainer",
"meshtastic",
] {
assert!(uses_orchestrator_install_flow(app));
assert!(should_try_orchestrator_install(app, true));
@@ -2327,6 +2547,10 @@ mod tests {
assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core");
assert_eq!(orchestrator_install_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
assert_eq!(
orchestrator_install_app_id("home-assistant"),
"homeassistant"
);
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");
}
+667 -92
View File
@@ -2,15 +2,18 @@ use super::config::{
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
get_memory_limit, is_valid_docker_image,
};
use super::dependencies::ordered_containers_for_start;
use super::dependencies::{ordered_containers_for_start, startup_order};
use super::install::install_log;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use anyhow::{Context, Result};
use archipelago_container::AppManifest;
use std::path::Path;
use std::process::Output;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::warn;
const PODMAN_CONTROL_TIMEOUT: Duration = Duration::from_secs(30);
@@ -53,7 +56,11 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let to_start = ordered_containers_for_start(package_id).await?;
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()]
} else {
ordered_containers_for_start(package_id).await?
};
if to_start.is_empty() {
tracing::warn!("package.start {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
@@ -124,7 +131,16 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let containers = get_containers_for_app(package_id).await?;
let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
let mut containers = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()]
} else {
get_containers_for_app(package_id).await?
};
if !single_orchestrator_app {
containers.reverse();
}
if containers.is_empty() {
tracing::warn!("package.stop {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
@@ -190,7 +206,13 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let containers = get_containers_for_app(package_id).await?;
let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
let containers = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()]
} else {
get_containers_for_app(package_id).await?
};
if containers.is_empty() {
tracing::warn!("package.restart {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id));
@@ -206,7 +228,11 @@ impl RpcHandler {
let package_id_owned = package_id.to_string();
let companion_app_id = package_id_owned.clone();
let to_restart = ordered_containers_for_start(package_id).await?;
let to_restart = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()]
} else {
ordered_containers_for_start(package_id).await?
};
let state_manager = Arc::clone(&self.state_manager);
let orchestrator = self.orchestrator.clone();
let pre_state =
@@ -323,7 +349,9 @@ impl RpcHandler {
match rm_out {
Ok(o) if o.status.success() => removed += 1,
Ok(o) => {
// If normal rm fails (e.g., still running), force as fallback
// If normal rm fails (e.g., still running/stopping/removing),
// force with targeted cleanup fallbacks. This is deliberately
// container-scoped; never prune the store during uninstall.
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!(
"Uninstall {}: rm {} failed ({}), trying force",
@@ -331,28 +359,36 @@ impl RpcHandler {
name,
stderr.trim()
);
let force_rm = podman_control(&["rm", "-f", name]).await;
match force_rm {
Ok(o2) if o2.status.success() => removed += 1,
_ => {
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
match force_remove_runtime_container(name).await {
Ok(()) => removed += 1,
Err(e) => {
let msg =
format!("Failed to remove {}: {}; {}", name, stderr.trim(), e);
tracing::error!("Uninstall {}: {}", package_id, msg);
errors.push(msg);
}
}
}
Err(e) => {
let msg = format!("Failed to remove {}: {}", name, e);
tracing::error!("Uninstall {}: {}", package_id, msg);
errors.push(msg);
}
Err(e) => match force_remove_runtime_container(name).await {
Ok(()) => removed += 1,
Err(force_err) => {
let msg = format!("Failed to remove {}: {}; {}", name, e, force_err);
tracing::error!("Uninstall {}: {}", package_id, msg);
errors.push(msg);
}
},
}
}
self.set_uninstall_stage(package_id, "Cleaning up volumes")
.await;
// Clean up dangling volumes associated with removed containers
let _ = podman_control(&["volume", "prune", "-f"]).await;
// Avoid global Podman volume prune on production nodes: store-wide
// Podman cleanup commands can block app health under load. App data is
// removed explicitly below when preserve_data=false.
tracing::info!(
package_id = %package_id,
"Skipping global podman volume prune during uninstall"
);
// Clean up app-specific networks (only if no other containers use them)
let app_networks: Vec<&str> = match package_id {
@@ -600,9 +636,25 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if let Err(e) = ensure_startable_container_state(name).await {
tracing::error!(container = %name, error = %e, "container is not startable");
errors.push(format!("{}: {}", name, e));
continue;
}
match inspect_runtime_container_state(name).await {
Ok(Some(state)) if state == "running" => {
tracing::debug!(container = %name, "container already running during package start");
continue;
}
Ok(_) => {}
Err(e) => {
tracing::warn!(container = %name, error = %e, "failed to re-inspect before package start")
}
}
repair_before_package_start(name).await;
wait_before_package_start(name).await;
tracing::info!("Starting container: {}", name);
let out = podman_control(&["start", name])
let out = podman_start_container(name)
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
@@ -669,6 +721,7 @@ async fn do_orchestrator_package_start(
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
repair_before_package_start(name).await;
wait_before_package_start(name).await;
match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => {
@@ -681,10 +734,13 @@ async fn do_orchestrator_package_start(
}
}
}
if errors.is_empty() {
Ok(())
} else {
if !errors.is_empty() {
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
} else {
for name in to_start {
ensure_runtime_host_port_listener(name).await?;
}
Ok(())
}
}
@@ -703,6 +759,137 @@ async fn podman_control(args: &[&str]) -> Result<Output> {
podman_with_timeout(args, podman_control_timeout(args)).await
}
async fn force_remove_runtime_container(container_name: &str) -> Result<()> {
for args in [
vec!["rm", "-f", container_name],
vec!["rm", "-f", "--time", "0", container_name],
] {
let output = podman_control(&args).await?;
if output.status.success()
|| is_missing_container_error(&String::from_utf8_lossy(&output.stderr))
{
return Ok(());
}
}
let _ = podman_control(&["container", "cleanup", container_name]).await;
let output = podman_control(&["rm", "-f", container_name]).await?;
if output.status.success()
|| is_missing_container_error(&String::from_utf8_lossy(&output.stderr))
{
return Ok(());
}
Err(anyhow::anyhow!(
"force remove failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
async fn force_stop_runtime_container(container_name: &str) -> Result<()> {
for args in [
vec!["stop", "-t", "0", container_name],
vec!["kill", container_name],
] {
let output = podman_control(&args).await?;
if output.status.success()
|| is_missing_container_error(&String::from_utf8_lossy(&output.stderr))
{
return Ok(());
}
}
for _ in 0..15 {
match inspect_runtime_container_state(container_name).await? {
None => return Ok(()),
Some(state) if matches!(state.as_str(), "exited" | "stopped" | "configured") => {
return Ok(())
}
Some(_) => tokio::time::sleep(Duration::from_secs(2)).await,
}
}
Err(anyhow::anyhow!(
"container did not reach stopped state after force stop"
))
}
async fn ensure_startable_container_state(container_name: &str) -> Result<()> {
let Some(state) = inspect_runtime_container_state(container_name).await? else {
return Ok(());
};
match state.as_str() {
"configured" | "created" | "exited" | "stopped" | "running" | "paused" => Ok(()),
"removing" => {
wait_for_container_absent_or_startable(container_name, Duration::from_secs(60)).await
}
other => Err(anyhow::anyhow!(
"container is in unsupported state before start: {}",
other
)),
}
}
async fn wait_for_container_absent_or_startable(
container_name: &str,
timeout: Duration,
) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
match inspect_runtime_container_state(container_name).await? {
None => return Ok(()),
Some(state)
if matches!(
state.as_str(),
"configured" | "created" | "exited" | "stopped" | "running" | "paused"
) =>
{
return Ok(())
}
Some(state) if state == "removing" && std::time::Instant::now() < deadline => {
tokio::time::sleep(Duration::from_secs(2)).await;
}
Some(state) if state == "removing" => {
force_remove_runtime_container(container_name).await?;
return Ok(());
}
Some(state) => {
return Err(anyhow::anyhow!(
"container is in unsupported state before start: {}",
state
))
}
}
}
}
async fn inspect_runtime_container_state(container_name: &str) -> Result<Option<String>> {
let output = podman_with_timeout(
&["inspect", container_name, "--format", "{{.State.Status}}"],
Duration::from_secs(10),
)
.await?;
if output.status.success() {
return Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_container_error(&stderr) {
Ok(None)
} else {
Err(anyhow::anyhow!("inspect failed: {}", stderr.trim()))
}
}
fn is_missing_container_error(stderr: &str) -> bool {
stderr.contains("no such container")
|| stderr.contains("no container with name")
|| stderr.contains("does not exist")
|| stderr.contains("not found")
}
fn podman_control_timeout(args: &[&str]) -> Duration {
args.windows(2)
.find_map(|pair| {
@@ -714,6 +901,13 @@ fn podman_control_timeout(args: &[&str]) -> Duration {
.unwrap_or(PODMAN_CONTROL_TIMEOUT)
}
fn podman_start_timeout(container_name: &str) -> Duration {
match container_name {
"immich_server" | "netbird-server" => Duration::from_secs(120),
_ => PODMAN_CONTROL_TIMEOUT,
}
}
async fn podman_with_timeout(args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(args);
@@ -732,12 +926,48 @@ async fn command_with_timeout(
.with_context(|| format!("Failed to exec {}", description))
}
async fn podman_start_container(container_name: &str) -> Result<Output> {
if !runtime_host_ports(container_name).is_empty() {
let mut cmd = tokio::process::Command::new("systemd-run");
cmd.args([
"--user",
"--scope",
"--quiet",
"--collect",
"podman",
"start",
])
.arg(container_name);
let scoped = command_with_timeout(
cmd,
podman_start_timeout(container_name),
&format!("systemd-run --user --scope podman start {container_name}"),
)
.await;
if scoped.as_ref().is_ok_and(|out| out.status.success()) {
return scoped;
}
if let Err(err) = &scoped {
tracing::warn!(
container = %container_name,
error = %err,
"scoped podman start failed; falling back to direct podman start"
);
}
}
podman_with_timeout(
&["start", container_name],
podman_start_timeout(container_name),
)
.await
}
async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String],
) -> Result<()> {
let mut errors = Vec::new();
for name in containers.iter().rev() {
for name in containers {
match orchestrator.stop(name).await {
Ok(()) => {}
Err(e) if is_unknown_app_id_error(&e) => {
@@ -758,6 +988,44 @@ async fn do_orchestrator_package_stop(
}
}
fn orchestrator_app_id(package_id: &str) -> &str {
match package_id {
"electrs" | "mempool-electrs" => "electrumx",
"home-assistant" => "homeassistant",
_ => package_id,
}
}
fn uses_single_orchestrator_app(package_id: &str) -> bool {
startup_order(package_id).is_empty()
&& matches!(
package_id,
"bitcoin-ui"
| "electrs-ui"
| "lnd-ui"
| "bitcoin-core"
| "bitcoin-knots"
| "lnd"
| "fedimint"
| "fedimint-gateway"
| "filebrowser"
| "electrumx"
| "electrs"
| "mempool-electrs"
| "homeassistant"
| "home-assistant"
| "nextcloud"
| "vaultwarden"
| "jellyfin"
| "photoprism"
| "uptime-kuma"
| "gitea"
| "portainer"
| "meshtastic"
| "botfights"
)
}
async fn do_orchestrator_package_restart(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
to_restart: &[String],
@@ -770,22 +1038,72 @@ async fn do_orchestrator_package_restart(
async fn do_package_stop(containers: &[String]) -> Result<()> {
let mut errors = Vec::new();
for name in containers {
match inspect_runtime_container_state(name).await {
Ok(None) => {
tracing::debug!(container = %name, "container already absent during stop");
continue;
}
Ok(Some(state)) if matches!(state.as_str(), "exited" | "stopped" | "configured") => {
tracing::debug!(container = %name, state = %state, "container already stopped");
continue;
}
Ok(Some(_)) => {}
Err(e) => {
tracing::warn!(container = %name, error = %e, "failed to inspect before stop")
}
}
tracing::info!(
"Stopping container: {} (timeout: {}s)",
name,
stop_timeout_secs(name)
);
let out = podman_control(&["stop", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman stop {}", name))?;
let out = match podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await {
Ok(out) => out,
Err(e) => {
tracing::warn!(
container = %name,
error = %e,
"podman stop errored, trying force stop"
);
match force_stop_runtime_container(name).await {
Ok(()) => {
tracing::info!(container = %name, "force stop after stop error succeeded");
continue;
}
Err(force_err) => {
tracing::error!(
"Failed to stop {}: {}; force stop failed: {}",
name,
e,
force_err
);
errors.push(format!("{}: {}; force stop failed: {}", name, e, force_err));
continue;
}
}
}
};
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if is_missing_companion_ok(name, &stderr) {
tracing::debug!(container = %name, "companion already absent during stop");
continue;
}
tracing::error!("Failed to stop {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
tracing::warn!("Failed to stop {}: {}, trying force stop", name, stderr);
match force_stop_runtime_container(name).await {
Ok(()) => {
tracing::info!(container = %name, "force stop after stop failure succeeded")
}
Err(e) => {
tracing::error!(
"Failed to stop {}: {}; force stop failed: {}",
name,
stderr,
e
);
errors.push(format!("{}: {}; force stop failed: {}", name, stderr, e));
}
}
}
}
if !errors.is_empty() {
@@ -801,6 +1119,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
for name in containers {
tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await;
wait_before_package_start(name).await;
let out = podman_control(&["restart", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman restart {}", name))?;
@@ -818,7 +1137,8 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
);
// Fallback: stop then start
let _ = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
let start_out = podman_control(&["start", name])
wait_before_package_start(name).await;
let start_out = podman_start_container(name)
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
@@ -855,22 +1175,158 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
async fn repair_before_package_start(container_name: &str) {
match container_name {
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
"indeedhub" => repair_indeedhub_network().await,
"immich_server" => repair_immich_dirs().await,
"netbird" => repair_netbird_network().await,
"grafana" => {
repair_grafana_dirs().await;
cleanup_stale_pasta_port("3000").await;
}
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"nextcloud" => {
repair_nextcloud_dirs().await;
cleanup_stale_pasta_port("8085").await;
}
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
cleanup_runtime_host_ports(container_name).await;
}
async fn wait_before_package_start(container_name: &str) {
match container_name {
"indeedhub" => wait_for_indeedhub_dependency_dns().await,
"immich_server" => wait_for_immich_dependencies().await,
"netbird" => wait_for_netbird_dependency_dns().await,
_ => {}
}
}
async fn wait_for_indeedhub_dependency_dns() {
for _ in 0..30 {
if indeedhub_frontend_dependencies_running().await {
super::stacks::repair_indeedhub_network_aliases().await;
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
for _ in 0..30 {
let ready = podman_with_timeout(
&["exec", "indeedhub-minio", "getent", "hosts", "minio"],
Duration::from_secs(5),
)
.await
.map(|out| out.status.success())
.unwrap_or(false);
if ready {
return;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn indeedhub_frontend_dependencies_running() -> bool {
for container in ["indeedhub-minio", "indeedhub-redis", "indeedhub-api"] {
if !container_is_running(container).await {
return false;
}
}
true
}
async fn container_is_running(container: &str) -> bool {
let Ok(output) = podman_with_timeout(
&["inspect", container, "--format", "{{.State.Status}}"],
Duration::from_secs(5),
)
.await
else {
return false;
};
output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "running"
}
async fn wait_for_netbird_dependency_dns() {
for _ in 0..30 {
if container_is_running("netbird-server").await
&& container_is_running("netbird-dashboard").await
{
super::stacks::repair_netbird_network_aliases().await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
return;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn wait_for_immich_dependencies() {
for _ in 0..60 {
if immich_postgres_ready().await && immich_redis_ready().await {
return;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn immich_postgres_ready() -> bool {
if container_health_is_healthy("immich_postgres").await {
return true;
}
let Ok(output) = podman_with_timeout(
&[
"exec",
"immich_postgres",
"pg_isready",
"-U",
"postgres",
"-d",
"immich",
],
Duration::from_secs(5),
)
.await
else {
return false;
};
output.status.success()
}
async fn immich_redis_ready() -> bool {
if container_health_is_healthy("immich_redis").await {
return true;
}
let Ok(output) = podman_with_timeout(
&["exec", "immich_redis", "valkey-cli", "ping"],
Duration::from_secs(5),
)
.await
else {
return false;
};
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("PONG")
}
async fn container_health_is_healthy(container: &str) -> bool {
let Ok(output) = podman_with_timeout(
&[
"inspect",
container,
"--format",
"{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
],
Duration::from_secs(5),
)
.await
else {
return false;
};
output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "healthy"
}
async fn repair_netbird_network() {
super::stacks::repair_netbird_network_aliases().await;
}
async fn repair_nginx_proxy_manager_container() {
@@ -1009,11 +1465,11 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
}
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
let Some(port) = runtime_required_host_port(container_name) else {
let Some(port) = runtime_host_ports(container_name).into_iter().next() else {
return Ok(());
};
if wait_for_runtime_host_port(port, 10).await {
if wait_for_runtime_host_port(container_name, port, 10).await {
return Ok(());
}
@@ -1035,7 +1491,7 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
));
}
if wait_for_runtime_host_port(port, 60).await {
if wait_for_runtime_host_port(container_name, port, 60).await {
install_log(&format!(
"START REPAIR OK: {} — host port {} is listening after restart",
container_name, port
@@ -1051,27 +1507,99 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
))
}
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name {
"grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"vaultwarden" => Some(8082),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(8081),
_ => None,
fn runtime_host_ports(container_name: &str) -> Vec<u16> {
let manifest_ports = manifest_host_ports(container_name);
if !manifest_ports.is_empty() {
return with_legacy_extra_ports(container_name, manifest_ports);
}
let ports = match container_name {
"grafana" => vec![3000],
"homeassistant" | "home-assistant" => vec![8123],
"jellyfin" => vec![8096],
"searxng" => vec![8888],
"uptime-kuma" => vec![3002],
"vaultwarden" => vec![8082],
"gitea" => vec![3001, 2222, 3000],
"nextcloud" => vec![8085],
"nginx-proxy-manager" => vec![8081, 8084, 8444],
_ => Vec::new(),
};
ports
}
fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16> {
if container_name == "gitea" && !ports.contains(&3000) {
ports.push(3000);
}
if container_name == "nginx-proxy-manager" {
for port in [8084, 8444] {
if !ports.contains(&port) {
ports.push(port);
}
}
}
ports
}
fn manifest_host_ports(container_name: &str) -> Vec<u16> {
for apps_dir in manifest_apps_dirs() {
let Ok(entries) = std::fs::read_dir(apps_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path().join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(manifest) = AppManifest::parse(&contents) else {
continue;
};
if manifest_container_name(&manifest) == container_name {
return manifest.app.ports.iter().map(|p| p.host).collect();
}
}
}
Vec::new()
}
fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
dirs.extend([
Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(),
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
]);
dirs
}
fn manifest_container_name(manifest: &AppManifest) -> String {
if let Some(v) = manifest.app.extensions.get("container_name") {
if let Some(s) = v.as_str() {
if !s.is_empty() {
return s.to_string();
}
}
}
match manifest.app.id.as_str() {
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
id => id.to_string(),
}
}
async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
let ready = match container_name {
"uptime-kuma" => http_host_port_ready(port, "/").await,
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok(),
};
if ready {
return true;
}
@@ -1083,6 +1611,37 @@ async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
}
}
async fn http_host_port_ready(port: u16, path: &str) -> bool {
let Ok(Ok(mut stream)) = tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
else {
return false;
};
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).await.is_err() {
return false;
}
let mut buf = [0u8; 128];
let Ok(Ok(n)) =
tokio::time::timeout(std::time::Duration::from_secs(3), stream.read(&mut buf)).await
else {
return false;
};
if n == 0 {
return false;
}
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.1 2")
|| head.starts_with("HTTP/1.1 3")
|| head.starts_with("HTTP/1.0 2")
|| head.starts_with("HTTP/1.0 3")
}
async fn repair_btcpay_dirs() {
let _ = tokio::process::Command::new("sudo")
.args([
@@ -1157,6 +1716,27 @@ async fn repair_nextcloud_dirs() {
}
}
async fn repair_immich_dirs() {
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/immich"])
.output()
.await;
let podman_chown = podman_control(&[
"unshare",
"chown",
"-R",
"0:0",
"/var/lib/archipelago/immich",
])
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "1000:1000", "/var/lib/archipelago/immich"])
.output()
.await;
}
}
async fn repair_btcpay_database_password() {
let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
@@ -1205,25 +1785,28 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
if container_name == "gitea" {
cleanup_gitea_stale_ports().await;
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
return;
}
}
match container_name {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
_ => {}
async fn cleanup_runtime_host_ports(container_name: &str) {
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
cleanup_ports(&[8081, 8084, 8444]).await;
}
async fn cleanup_ports(ports: &[u16]) {
for port in ports {
cleanup_stale_pasta_port(&port.to_string()).await;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
@@ -1249,31 +1832,6 @@ async fn cleanup_stale_pasta_port(port: &str) {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn cleanup_gitea_stale_ports() {
for port in ["3001", "2222", "3000"] {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
matches!(
name,
@@ -1352,3 +1910,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
_ => vec![package_id.to_string()],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
assert_eq!(runtime_host_ports("jellyfin"), vec![8096]);
assert_eq!(runtime_host_ports("uptime-kuma"), vec![3002]);
}
#[test]
fn runtime_host_ports_preserve_legacy_extra_ports() {
assert_eq!(runtime_host_ports("gitea"), vec![3001, 2222, 3000]);
}
}
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -16,6 +16,8 @@ use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// This is a manual operation — the user clicks "Update" in the UI.
@@ -327,6 +329,7 @@ impl RpcHandler {
if archipelago_container::image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.kill_on_drop(true);
let mut child = cmd
.arg(image)
.stdout(std::process::Stdio::piped())
@@ -334,23 +337,38 @@ impl RpcHandler {
.spawn()
.context("Failed to start image pull")?;
if let Some(stderr) = child.stderr.take() {
let progress_task = if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
Some(tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
}
}
}
}
}))
} else {
None
};
let status = child
.wait()
.await
.context("Failed to wait for image pull")?;
let status = match tokio::time::timeout(PODMAN_UPDATE_PULL_TIMEOUT, child.wait()).await {
Ok(result) => result.context("Failed to wait for image pull")?,
Err(_) => {
let _ = child.kill().await;
return Err(anyhow::anyhow!(
"podman pull {} timed out after {}s",
image,
PODMAN_UPDATE_PULL_TIMEOUT.as_secs()
));
}
};
if let Some(task) = progress_task {
let _ = task.await;
}
if !status.success() {
return Err(anyhow::anyhow!("podman pull {} failed", image));
}
@@ -430,7 +448,6 @@ fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool
fn orchestrator_update_app_id(package_id: &str) -> &str {
match package_id {
"bitcoin-knots" => "bitcoin-core",
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
@@ -459,8 +476,8 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
match container_name {
"bitcoin-knots" | "bitcoin-core" => {
push("bitcoin-core");
push("bitcoin-knots");
push("bitcoin-core");
}
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
@@ -525,7 +542,7 @@ mod tests {
fn container_name_candidates_cover_common_aliases() {
assert_eq!(
candidate_app_ids_for_container("bitcoin-knots"),
vec!["bitcoin-core", "bitcoin-knots"]
vec!["bitcoin-knots", "bitcoin-core"]
);
assert_eq!(
candidate_app_ids_for_container("archy-bitcoin-ui"),
@@ -543,7 +560,8 @@ mod tests {
#[test]
fn update_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-core");
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-knots");
assert_eq!(orchestrator_update_app_id("bitcoin-core"), "bitcoin-core");
assert_eq!(orchestrator_update_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("fedimint"), "fedimint");
+125 -22
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::{debug, info};
use tracing::{debug, info, warn};
impl RpcHandler {
/// server.set-name — Rename the server (persisted to data_dir/server-name)
@@ -32,6 +32,21 @@ impl RpcHandler {
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
let hostname = hostname_from_server_name(&name);
let hostname_result = set_system_hostname(&hostname).await;
let (hostname_updated, hostname_error) = match hostname_result {
Ok(()) => (true, None),
Err(e) => {
warn!(
name = %name,
hostname = %hostname,
"Server name persisted but OS hostname update failed: {}",
e
);
(false, Some(e.to_string()))
}
};
info!("Server name updated to: {}", name);
// Push the new name to federation peers in background
@@ -43,7 +58,12 @@ impl RpcHandler {
}
});
Ok(serde_json::json!({ "name": name }))
Ok(serde_json::json!({
"name": name,
"hostname": hostname,
"hostname_updated": hostname_updated,
"hostname_error": hostname_error,
}))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
@@ -155,21 +175,7 @@ impl RpcHandler {
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
// 1. Prune dangling container images
match prune_container_images().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Pruned dangling images: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
}
// 2. Clean old log files (> 30 days)
// 1. Clean old log files (> 30 days)
match clean_old_logs(30).await {
Ok(bytes) => {
if bytes > 0 {
@@ -180,7 +186,20 @@ impl RpcHandler {
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
}
// 3. Remove stale temp files
match vacuum_journal_logs("200M").await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Vacuumed journal logs: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Journal cleanup failed: {}", e)),
}
// 2. Remove stale temp files
match clean_temp_files().await {
Ok(bytes) => {
if bytes > 0 {
@@ -191,17 +210,53 @@ impl RpcHandler {
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
}
// 4. Prune container build cache
match prune_build_cache().await {
// 3. Keep only the most recent backend deploy backups. These are useful
// for rollback, but a long-lived alpha node can accumulate gigabytes of
// old binaries under /usr/local/bin.
match clean_backend_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
actions.push(format!(
"Removed old backend backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
Err(e) => actions.push(format!("Backend backup cleanup failed: {}", e)),
}
match clean_legacy_backend_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Removed old legacy backend backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Legacy backend backup cleanup failed: {}", e)),
}
match clean_web_ui_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Removed old web UI backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Web UI backup cleanup failed: {}", e)),
}
actions.push(
"Skipped Podman image/volume prune: Podman store commands can block app health on busy nodes"
.to_string(),
);
tracing::info!(
"Disk cleanup complete: {} freed ({} actions)",
format_bytes(freed_bytes),
@@ -216,6 +271,54 @@ impl RpcHandler {
}
}
pub(super) fn hostname_from_server_name(name: &str) -> String {
let mut hostname = String::with_capacity(name.len());
let mut previous_dash = false;
for c in name.trim().chars().flat_map(char::to_lowercase) {
let valid = c.is_ascii_lowercase() || c.is_ascii_digit();
if valid {
hostname.push(c);
previous_dash = false;
} else if !previous_dash {
hostname.push('-');
previous_dash = true;
}
if hostname.len() >= 63 {
break;
}
}
let hostname = hostname.trim_matches('-').to_string();
if hostname.is_empty() {
"archipelago".to_string()
} else {
hostname
}
}
async fn set_system_hostname(hostname: &str) -> Result<()> {
let output = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
.output()
.await
.context("Failed to run hostnamectl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(
"{}",
if stderr.is_empty() {
"hostnamectl failed".to_string()
} else {
stderr
}
);
}
Ok(())
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
+346 -47
View File
@@ -1,6 +1,9 @@
mod handlers;
use crate::update::host_sudo;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tracing::{debug, info};
/// Push the server name to all federation peers by syncing state.
@@ -301,53 +304,12 @@ pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Valu
Ok(devices)
}
/// Prune dangling container images via `podman image prune -f`.
/// Returns estimated bytes freed.
pub(super) async fn prune_container_images() -> Result<u64> {
let output = tokio::process::Command::new("podman")
.args(["image", "prune", "-f"])
.output()
.await
.context("Failed to run podman image prune")?;
if !output.status.success() {
anyhow::bail!(
"podman image prune failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// Podman outputs image IDs, estimate ~100MB per pruned image
let stdout = String::from_utf8_lossy(&output.stdout);
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
Ok(pruned_count as u64 * 100_000_000) // rough estimate
}
/// Prune container build cache via `podman system prune -f`.
pub(super) async fn prune_build_cache() -> Result<u64> {
// Just prune volumes and build cache (not containers or images — those are handled above)
let output = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
.output()
.await
.context("Failed to run podman volume prune")?;
if !output.status.success() {
anyhow::bail!(
"podman volume prune failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
Ok(pruned_count as u64 * 10_000_000) // rough estimate per volume
}
/// Clean log files older than `max_age_days` from common log directories.
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
let output = tokio::process::Command::new("sudo")
let output = tokio::process::Command::new("timeout")
.args([
"60s",
"sudo",
"find",
"/var/log",
"-type",
@@ -366,8 +328,10 @@ pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
let stdout = String::from_utf8_lossy(&output.stdout);
let deleted_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
// Also clean rotated/compressed logs
let _ = tokio::process::Command::new("sudo")
let _ = tokio::process::Command::new("timeout")
.args([
"60s",
"sudo",
"find",
"/var/log",
"-type",
@@ -384,14 +348,81 @@ pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
Ok(deleted_count as u64 * 500_000) // rough estimate per log file
}
/// Vacuum systemd journals to a bounded size. Returns measured bytes freed.
pub(super) async fn vacuum_journal_logs(max_size: &str) -> Result<u64> {
let before = journal_disk_usage().await.unwrap_or(0);
let output = tokio::process::Command::new("timeout")
.args(["60s", "sudo", "journalctl", "--vacuum-size", max_size])
.output()
.await
.context("Failed to run journal vacuum")?;
if !output.status.success() {
anyhow::bail!(
"journal vacuum failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let after = journal_disk_usage().await.unwrap_or(before);
Ok(before.saturating_sub(after))
}
async fn journal_disk_usage() -> Result<u64> {
let output = tokio::process::Command::new("sudo")
.args(["-n", "journalctl", "--disk-usage"])
.output()
.await
.context("Failed to read journal disk usage")?;
if !output.status.success() {
anyhow::bail!(
"journalctl --disk-usage failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
parse_journal_disk_usage(&String::from_utf8_lossy(&output.stdout))
.ok_or_else(|| anyhow::anyhow!("could not parse journal disk usage"))
}
fn parse_journal_disk_usage(output: &str) -> Option<u64> {
let mut parts = output.split_whitespace();
while let Some(part) = parts.next() {
let (number, inline_unit) = split_number_unit(part);
let Ok(value) = number.parse::<f64>() else {
continue;
};
let unit = inline_unit.unwrap_or_else(|| parts.next().unwrap_or_default());
let multiplier = match unit {
"B" | "bytes" => 1.0,
"K" | "KB" | "KiB" => 1024.0,
"M" | "MB" | "MiB" => 1024.0 * 1024.0,
"G" | "GB" | "GiB" => 1024.0 * 1024.0 * 1024.0,
_ => continue,
};
return Some((value * multiplier) as u64);
}
None
}
fn split_number_unit(value: &str) -> (&str, Option<&str>) {
let split_at = value
.char_indices()
.find_map(|(idx, ch)| (!ch.is_ascii_digit() && ch != '.').then_some(idx))
.unwrap_or(value.len());
let (number, unit) = value.split_at(split_at);
(number, (!unit.is_empty()).then_some(unit))
}
/// Remove stale temp files from /tmp and /var/tmp.
pub(super) async fn clean_temp_files() -> Result<u64> {
let mut freed = 0u64;
for dir in &["/tmp", "/var/tmp"] {
let output = tokio::process::Command::new("sudo")
let output = tokio::process::Command::new("timeout")
.args([
"find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
"45s", "sudo", "find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
])
.output()
.await;
@@ -406,6 +437,177 @@ pub(super) async fn clean_temp_files() -> Result<u64> {
Ok(freed)
}
/// Keep the newest timestamped backend backups and remove older ones.
pub(super) async fn clean_backend_backups(keep: usize) -> Result<u64> {
clean_backend_backups_in(Path::new("/usr/local/bin"), keep).await
}
/// Keep the newest legacy backend backups and remove older alpha-era deploy artifacts.
pub(super) async fn clean_legacy_backend_backups(keep: usize) -> Result<u64> {
clean_named_backups_in(
Path::new("/usr/local/bin"),
keep,
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
false,
)
.await
}
/// Keep the newest web UI rollback backups and remove older copies.
pub(super) async fn clean_web_ui_backups(keep: usize) -> Result<u64> {
clean_named_backups_in(
Path::new("/opt/archipelago"),
keep,
|name| name.starts_with("web-ui.bak") || name == "web-ui.old",
true,
)
.await
}
async fn clean_backend_backups_in(dir: &Path, keep: usize) -> Result<u64> {
let mut backups = backend_backup_candidates(dir).await?;
remove_old_backups(&mut backups, keep, false).await
}
async fn clean_named_backups_in(
dir: &Path,
keep: usize,
matches_name: impl Fn(&str) -> bool,
allow_dirs: bool,
) -> Result<u64> {
let mut backups = named_backup_candidates(dir, matches_name, allow_dirs).await?;
remove_old_backups(&mut backups, keep, allow_dirs).await
}
async fn remove_old_backups(
backups: &mut Vec<BackupArtifact>,
keep: usize,
allow_dirs: bool,
) -> Result<u64> {
backups.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
.then_with(|| b.name.cmp(&a.name))
});
let mut freed = 0u64;
for backup in backups.iter().skip(keep) {
let remove_result = if backup.is_dir && allow_dirs {
tokio::fs::remove_dir_all(&backup.path).await
} else {
tokio::fs::remove_file(&backup.path).await
};
match remove_result {
Ok(()) => freed += backup.size,
Err(_) => {
remove_path_with_sudo(&backup.path, backup.is_dir && allow_dirs).await?;
freed += backup.size;
}
}
}
Ok(freed)
}
async fn remove_path_with_sudo(path: &Path, recursive: bool) -> Result<()> {
let path = path.to_string_lossy();
let args = if recursive {
vec!["rm", "-rf", path.as_ref()]
} else {
vec!["rm", "-f", path.as_ref()]
};
let status = host_sudo(&args)
.await
.with_context(|| format!("removing {path} via sudo"))?;
if !status.success() {
anyhow::bail!(
"sudo rm {} {path} exited with {status}",
if recursive { "-rf" } else { "-f" }
);
}
Ok(())
}
#[derive(Debug)]
struct BackupArtifact {
path: PathBuf,
name: String,
modified: SystemTime,
size: u64,
is_dir: bool,
}
async fn backend_backup_candidates(dir: &Path) -> Result<Vec<BackupArtifact>> {
named_backup_candidates(
dir,
|name| {
name.strip_prefix("archipelago.backup-")
.is_some_and(|suffix| !suffix.is_empty() && !suffix.contains('/'))
},
false,
)
.await
}
async fn named_backup_candidates(
dir: &Path,
matches_name: impl Fn(&str) -> bool,
allow_dirs: bool,
) -> Result<Vec<BackupArtifact>> {
let mut backups = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(backups),
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !matches_name(&name) {
continue;
}
let meta = entry.metadata().await?;
if !meta.is_file() && !(allow_dirs && meta.is_dir()) {
continue;
}
backups.push(BackupArtifact {
path: entry.path(),
name: name.to_string(),
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size: path_size(&entry.path(), &meta).await.unwrap_or(meta.len()),
is_dir: meta.is_dir(),
});
}
Ok(backups)
}
async fn path_size(path: &Path, meta: &std::fs::Metadata) -> Result<u64> {
if meta.is_file() {
return Ok(meta.len());
}
if !meta.is_dir() {
return Ok(0);
}
let output = tokio::process::Command::new("du")
.args(["-sb", &path.to_string_lossy()])
.output()
.await
.with_context(|| format!("du -sb {}", path.display()))?;
if !output.status.success() {
anyhow::bail!("du -sb {} failed", path.display());
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("du output missing size for {}", path.display()))?
.parse::<u64>()
.with_context(|| format!("parse du size for {}", path.display()))
}
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
@@ -422,6 +624,103 @@ pub(super) fn format_bytes(bytes: u64) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn backend_backup_cleanup_keeps_newest_files() {
let dir = tempfile::tempdir().unwrap();
for name in [
"archipelago.backup-20260501",
"archipelago.backup-20260502",
"archipelago.backup-20260503",
"archipelago.backup-20260504",
"archipelago.backup-20260505",
"archipelago.bak",
"archipelago",
] {
tokio::fs::write(dir.path().join(name), b"12345")
.await
.unwrap();
}
let freed = clean_backend_backups_in(dir.path(), 3).await.unwrap();
assert_eq!(freed, 10);
assert!(!dir.path().join("archipelago.backup-20260501").exists());
assert!(!dir.path().join("archipelago.backup-20260502").exists());
assert!(dir.path().join("archipelago.backup-20260503").exists());
assert!(dir.path().join("archipelago.backup-20260504").exists());
assert!(dir.path().join("archipelago.backup-20260505").exists());
assert!(dir.path().join("archipelago.bak").exists());
assert!(dir.path().join("archipelago").exists());
}
#[tokio::test]
async fn legacy_backend_backup_cleanup_keeps_newest_matching_files() {
let dir = tempfile::tempdir().unwrap();
for name in [
"archipelago.bak-1",
"archipelago.bak-2",
"archipelago.before-3",
"archipelago.backup-keep-separate",
"archipelago",
] {
tokio::fs::write(dir.path().join(name), b"12345")
.await
.unwrap();
}
let freed = clean_named_backups_in(
dir.path(),
1,
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
false,
)
.await
.unwrap();
assert_eq!(freed, 10);
assert_eq!(
[
"archipelago.bak-1",
"archipelago.bak-2",
"archipelago.before-3"
]
.into_iter()
.filter(|name| dir.path().join(name).exists())
.count(),
1
);
assert!(dir.path().join("archipelago.backup-keep-separate").exists());
assert!(dir.path().join("archipelago").exists());
}
#[test]
fn hostname_from_server_name_derives_linux_safe_hostname() {
assert_eq!(
handlers::hostname_from_server_name("My Archipelago Node"),
"my-archipelago-node"
);
assert_eq!(
handlers::hostname_from_server_name("Kitchen_Node!! 01"),
"kitchen-node-01"
);
assert_eq!(handlers::hostname_from_server_name("!!!"), "archipelago");
}
#[test]
fn parses_journal_disk_usage() {
assert_eq!(
parse_journal_disk_usage(
"Archived and active journals take up 463.9M in the file system."
),
Some(486_434_406)
);
}
}
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
let mut temps = Vec::new();
+4
View File
@@ -133,6 +133,10 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
}
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
+35 -3
View File
@@ -86,6 +86,11 @@ pub struct AuthManager {
data_dir: PathBuf,
}
pub struct ChangePasswordOutcome {
pub ssh_updated: bool,
pub ssh_error: Option<String>,
}
impl AuthManager {
pub fn new(data_dir: PathBuf) -> Self {
Self { data_dir }
@@ -288,7 +293,7 @@ impl AuthManager {
current_password: &str,
new_password: &str,
also_change_ssh: bool,
) -> Result<()> {
) -> Result<ChangePasswordOutcome> {
if !self.verify_password(current_password).await? {
anyhow::bail!("Current password is incorrect");
}
@@ -314,11 +319,21 @@ impl AuthManager {
let content = serde_json::to_string_pretty(&user)?;
fs::write(&user_file, content).await?;
let mut outcome = ChangePasswordOutcome {
ssh_updated: false,
ssh_error: None,
};
if also_change_ssh {
change_ssh_password(new_password).await?;
match change_ssh_password(new_password).await {
Ok(()) => outcome.ssh_updated = true,
Err(e) => {
tracing::warn!("Web password changed but SSH password update failed: {}", e);
outcome.ssh_error = Some(e.to_string());
}
}
}
Ok(())
Ok(outcome)
}
}
@@ -485,6 +500,23 @@ mod tests {
assert!(validate_password_strength("MyP@ssw0rd!123").is_ok());
}
#[tokio::test]
async fn test_change_password_updates_web_password_without_ssh() {
let dir = tempfile::tempdir().unwrap();
let auth = AuthManager::new(dir.path().to_path_buf());
auth.setup_user("password123").await.unwrap();
let outcome = auth
.change_password("password123", "MyP@ssw0rd!123", false)
.await
.unwrap();
assert!(!outcome.ssh_updated);
assert!(outcome.ssh_error.is_none());
assert!(auth.verify_password("MyP@ssw0rd!123").await.unwrap());
assert!(!auth.verify_password("password123").await.unwrap());
}
#[test]
fn test_validate_password_strength_too_short() {
assert!(validate_password_strength("Ab1!").is_err());
+76 -9
View File
@@ -13,7 +13,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tracing::{debug, warn};
const CACHE_REFRESH_SECS: u64 = 5;
const CACHE_REFRESH_SECS: u64 = 10;
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
#[derive(Debug, Clone, Serialize)]
pub struct BitcoinNodeStatus {
@@ -65,6 +66,36 @@ fn transient_error(err_msg: &str) -> bool {
|| lower.contains("broken pipe")
|| lower.contains("eof")
|| lower.contains("500 internal server error")
|| lower.contains("503 service unavailable")
|| lower.contains("work queue depth exceeded")
|| lower.contains("decode bitcoin rpc json")
|| lower.contains("error decoding response body")
|| lower.contains("expected value at line 1 column 1")
}
fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
let detail = err_msg
.lines()
.next()
.unwrap_or(err_msg)
.trim()
.trim_end_matches('.');
let lower = detail.to_lowercase();
let state = if lower.contains("verifying blocks") {
"verifying blocks after restart"
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
"waiting for the Bitcoin RPC listener"
} else if lower.contains("timed out") || lower.contains("timeout") {
"busy and not answering RPC before the timeout"
} else {
"starting or busy syncing"
};
if has_cached_state {
format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
} else {
format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
}
}
pub fn spawn_status_cache() {
@@ -72,6 +103,7 @@ pub fn spawn_status_cache() {
loop {
let fresh = fetch_bitcoin_status().await;
let mut cached = cache().write().await;
let mut sleep_secs = CACHE_REFRESH_SECS;
match fresh {
Ok(mut status) => {
status.ok = true;
@@ -80,33 +112,31 @@ pub fn spawn_status_cache() {
*cached = status;
}
Err(e) => {
let err_msg = e.to_string();
let err_msg = format!("{e:#}");
if transient_error(&err_msg) {
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
} else {
warn!("Bitcoin status: RPC failure: {}", err_msg);
}
sleep_secs = CACHE_ERROR_BACKOFF_SECS;
if cached.blockchain_info.is_some() {
cached.ok = false;
cached.stale = true;
cached.error = Some(format!(
"Bitcoin node is reconnecting; showing last known state: {}",
err_msg
));
cached.error = Some(friendly_transient_error(true, &err_msg));
} else {
*cached = BitcoinNodeStatus {
ok: false,
stale: false,
updated_at_ms: now_ms(),
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
error: Some(friendly_transient_error(false, &err_msg)),
..BitcoinNodeStatus::default()
};
}
}
}
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
}
});
}
@@ -117,7 +147,7 @@ pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.timeout(Duration::from_secs(20))
.build()
.context("build Bitcoin status HTTP client")?;
@@ -183,3 +213,40 @@ async fn bitcoin_rpc_call(
.cloned()
.context("missing Bitcoin RPC result")
}
#[cfg(test)]
mod tests {
use super::friendly_transient_error;
#[test]
fn explains_verifying_blocks_without_generic_timeout_copy() {
let msg = friendly_transient_error(
false,
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
);
assert!(msg.contains("verifying blocks after restart"));
assert!(msg.contains("retrying automatically"));
}
#[test]
fn explains_missing_rpc_listener() {
let msg = friendly_transient_error(
true,
"getblockchaininfo: tcp connect error: Connection refused (os error 111)",
);
assert!(msg.contains("waiting for the Bitcoin RPC listener"));
assert!(msg.contains("showing last known state"));
}
#[test]
fn explains_rpc_timeout() {
let msg = friendly_transient_error(
false,
"getblockchaininfo: Bitcoin RPC request failed: operation timed out",
);
assert!(msg.contains("busy and not answering RPC before the timeout"));
}
}
@@ -23,5 +23,15 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /rpc/v1 {
proxy_pass http://127.0.0.1:5678/rpc/v1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location / { try_files $uri $uri/ /index.html; }
}
@@ -34,6 +34,7 @@ pub struct BootReconciler {
/// `systemctl --user` and `podman`, which both block real time
/// and would race the paused-clock test fixtures.
companion_stage: bool,
wait_for_recovery: bool,
}
impl BootReconciler {
@@ -47,6 +48,7 @@ impl BootReconciler {
interval,
shutdown,
companion_stage: true,
wait_for_recovery: true,
}
}
@@ -56,6 +58,7 @@ impl BootReconciler {
#[cfg(test)]
pub fn without_companion_stage(mut self) -> Self {
self.companion_stage = false;
self.wait_for_recovery = false;
self
}
@@ -78,6 +81,21 @@ impl BootReconciler {
/// by the orchestrator, and companion failures are logged but never
/// propagated.
pub async fn run_forever(self) {
let wait_start = Instant::now();
while self.wait_for_recovery && !crate::crash_recovery::is_recovery_complete() {
if wait_start.elapsed() > Duration::from_secs(1800) {
tracing::warn!("boot reconciler: boot recovery did not complete within 30 minutes, starting anyway");
break;
}
tokio::select! {
_ = time::sleep(Duration::from_secs(5)) => {}
_ = self.shutdown.notified() => {
tracing::info!("boot reconciler: shutdown requested before recovery completed");
return;
}
}
}
// Initial pass: no delay.
self.tick().await;
@@ -244,58 +262,65 @@ mod tests {
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
orch.set_disk_gb_for_test(2_000);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
pull_manifest("test-app", "docker.io/example/test-app:1"),
PathBuf::from("/tmp/test-app"),
)
.await;
orch
}
#[tokio::test(start_paused = true)]
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
for _ in 0..100 {
let count = rt.status_call_count();
if count >= expected {
return count;
}
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
rt.status_call_count()
}
#[tokio::test]
async fn initial_pass_fires_immediately() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
BootReconciler::new(orch.clone(), Duration::from_millis(50), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
// Yield so the spawned task gets CPU to run its initial reconcile.
tokio::task::yield_now().await;
tokio::task::yield_now().await;
// We expect exactly one reconcile pass to have run by now (the initial),
// NOT a second one (the 30s sleep hasn't elapsed in paused time).
assert_eq!(rt.status_call_count(), 1, "initial pass should fire once");
assert_eq!(
wait_for_status_calls(&rt, 1).await,
1,
"initial pass should fire once"
);
shutdown.notify_one();
// Under paused clock the select! is blocked on sleep_until; the notify
// will unblock it. Advance wall-clock a hair so the notify gets polled.
tokio::task::yield_now().await;
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
#[tokio::test(start_paused = true)]
#[tokio::test]
async fn second_pass_fires_after_interval() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
BootReconciler::new(orch.clone(), Duration::from_millis(10), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(rt.status_call_count(), 1);
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
// Fast-forward past one interval; the sleep_until should fire.
tokio::time::advance(Duration::from_secs(31)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(20)).await;
wait_for_status_calls(&rt, 2).await;
assert_eq!(
rt.status_call_count(),
@@ -308,27 +333,23 @@ mod tests {
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
#[tokio::test(start_paused = true)]
#[tokio::test]
async fn shutdown_terminates_loop() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
BootReconciler::new(orch.clone(), Duration::from_millis(50), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
wait_for_status_calls(&rt, 1).await;
shutdown.notify_one();
// The select! should wake on Notified and return. Use a real timeout
// with advancing the paused clock to make sure the task exits.
tokio::time::advance(Duration::from_millis(10)).await;
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(result.is_ok(), "reconciler did not exit after shutdown");
}
#[tokio::test(start_paused = true)]
#[tokio::test]
async fn failure_in_one_pass_does_not_stop_loop() {
// Manifest references a container the runtime does not have AND
// cannot create (no install path — install_fresh will also fail to
@@ -344,26 +365,23 @@ mod tests {
);
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
orch.set_disk_gb_for_test(2_000);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
pull_manifest("test-app", "docker.io/example/test-app:1"),
PathBuf::from("/tmp/test-app"),
)
.await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
BootReconciler::new(orch.clone(), Duration::from_millis(10), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let first = rt.status_call_count();
let first = wait_for_status_calls(&rt, 1).await;
assert!(first >= 1, "initial pass should have touched the runtime");
// Advance one interval — second pass should fire regardless of what
// the first pass did.
tokio::time::advance(Duration::from_secs(31)).await;
tokio::time::sleep(Duration::from_millis(20)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let second = rt.status_call_count();
@@ -373,7 +391,6 @@ mod tests {
);
shutdown.notify_one();
tokio::time::advance(Duration::from_millis(10)).await;
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
}
+67 -10
View File
@@ -9,6 +9,7 @@
//! | bitcoin-core | archy-bitcoin-ui | RPC viewer |
//! | lnd | archy-lnd-ui | wallet/channel UI |
//! | electrumx | archy-electrs-ui | indexer status UI |
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
//!
//! Lifecycle: `install` writes a Quadlet `.container` unit to
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
@@ -22,6 +23,7 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tokio::process::Command;
use tracing::{info, warn};
@@ -30,6 +32,9 @@ use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
use archipelago_container::image_uses_insecure_registry;
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
/// Static description of one companion. The full list per backend
/// app_id lives in `companions_for`.
@@ -65,6 +70,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => BITCOIN_UI,
"lnd" => LND_UI,
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
"fedimint" | "fedimintd" => FEDIMINT_UI,
_ => &[],
}
}
@@ -114,6 +120,20 @@ const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
host_network: true,
}];
const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-fedimint-ui",
image_base: "fedimint-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/fedimint-ui",
"/home/archipelago/archy/docker/fedimint-ui",
"/home/archipelago/Projects/archy/docker/fedimint-ui",
],
pre_start: None,
bind_mounts: &[],
ports: &[],
host_network: true,
}];
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
Box::pin(async {
let paths = crate::container::bitcoin_ui::RenderPaths::default();
@@ -201,11 +221,12 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
return Ok(local_image);
}
info!(companion = spec.name, "building locally from {dir}");
let out = Command::new("podman")
.args(["build", "-t", &local_image, dir])
.output()
.await
.context("spawn podman build")?;
let out = command_output_with_timeout(
Command::new("podman").args(["build", "-t", &local_image, dir]),
COMPANION_BUILD_TIMEOUT,
"podman build companion image",
)
.await?;
if out.status.success() {
return Ok(local_image);
}
@@ -226,7 +247,12 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
cmd.arg("--tls-verify=false");
}
cmd.arg(&registry_image);
let out = cmd.output().await.context("spawn podman pull")?;
let out = command_output_with_timeout(
&mut cmd,
COMPANION_PULL_TIMEOUT,
"podman pull companion image",
)
.await?;
if !out.status.success() {
anyhow::bail!(
"no local Dockerfile and registry pull failed for {}: {}",
@@ -238,11 +264,31 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
}
async fn image_exists(image: &str) -> bool {
Command::new("podman")
.args(["image", "exists", image])
.status()
let mut cmd = Command::new("podman");
cmd.args(["image", "inspect", image]);
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
Ok(Ok(status)) => status.success(),
Ok(Err(err)) => {
warn!(image = %image, error = %err, "companion image existence check failed");
false
}
Err(_) => {
warn!(image = %image, "companion image existence check timed out");
false
}
}
}
async fn command_output_with_timeout(
cmd: &mut Command,
timeout: Duration,
description: &str,
) -> Result<std::process::Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.is_ok_and(|status| status.success())
.with_context(|| format!("{description} timed out after {}s", timeout.as_secs()))?
.with_context(|| format!("spawn {description}"))
}
fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
@@ -368,6 +414,8 @@ mod tests {
assert_eq!(companions_for("electrumx").len(), 1);
assert_eq!(companions_for("electrs").len(), 1);
assert_eq!(companions_for("mempool-electrs").len(), 1);
assert_eq!(companions_for("fedimint").len(), 1);
assert_eq!(companions_for("fedimintd").len(), 1);
assert_eq!(companions_for("nextcloud").len(), 0);
assert_eq!(companions_for("not-a-real-app").len(), 0);
}
@@ -398,4 +446,13 @@ mod tests {
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
}
#[test]
fn fedimint_ui_uses_host_network_for_public_guardian_port() {
let spec = &FEDIMINT_UI[0];
let u = build_unit(spec, "localhost/fedimint-ui:latest");
assert_eq!(u.name, "archy-fedimint-ui");
assert!(matches!(u.network, NetworkMode::Host));
assert!(u.ports.is_empty());
}
}
@@ -26,13 +26,7 @@ impl DockerPackageScanner {
/// Scan Docker containers and convert to package data
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
let containers = match self.runtime.list_containers().await {
Ok(c) => c,
Err(e) => {
debug!("Failed to list containers: {}", e);
return Ok(HashMap::new());
}
};
let containers = self.runtime.list_containers().await?;
debug!("Found {} containers", containers.len());
@@ -61,6 +55,8 @@ impl DockerPackageScanner {
"indeedhub-build_minio-init_1",
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"buildx_buildkit_default",
];
@@ -117,6 +113,11 @@ impl DockerPackageScanner {
continue;
}
if is_transient_podman_helper(&app_id, &container.ports) {
debug!("Skipping transient Podman helper container: {}", app_id);
continue;
}
// Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1)
// These have the project prefix pattern: {project}_{service}_{instance}
if app_id.starts_with("indeedhub-build_") {
@@ -139,7 +140,9 @@ impl DockerPackageScanner {
let metadata = get_app_metadata(&app_id);
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
let lan_address = if app_id == "netbird" {
reachable_lan_address(&app_id, netbird_configured_launch_url().await).await
} else if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
reachable_lan_address(&app_id, Some(ui_address.clone())).await
@@ -273,7 +276,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
// Recommended: enhanced functionality
"fedimint" | "fedimint-gateway" => "recommended",
@@ -288,6 +290,21 @@ fn get_app_tier(app_id: &str) -> &'static str {
}
}
fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
if !ports.is_empty() {
return false;
}
let Some((left, right)) = app_id.split_once('_') else {
return false;
};
!left.is_empty()
&& !right.is_empty()
&& left.chars().all(|c| c.is_ascii_lowercase())
&& right.chars().all(|c| c.is_ascii_lowercase())
}
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin-core" => AppMetadata {
@@ -481,11 +498,18 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
},
"netbird" => AppMetadata {
title: "NetBird".to_string(),
description: "WireGuard mesh VPN client for secure remote access".to_string(),
description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(),
icon: "/assets/img/app-icons/netbird.svg".to_string(),
repo: "https://github.com/netbirdio/netbird".to_string(),
tier: "",
},
"gitea" => AppMetadata {
title: "Gitea".to_string(),
description: "Self-hosted Git service with repository and package hosting".to_string(),
icon: "/assets/img/app-icons/gitea.svg".to_string(),
repo: "https://gitea.com".to_string(),
tier: "",
},
"indeedhub" | "indeehub" => AppMetadata {
title: "IndeedHub".to_string(),
description: "Decentralized media streaming platform".to_string(),
@@ -493,13 +517,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
icon: "/assets/img/app-icons/dwn.svg".to_string(),
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
tier: "",
},
"tor" | "archy-tor" => AppMetadata {
title: "Tor".to_string(),
description: "Anonymous overlay network for privacy".to_string(),
@@ -665,6 +682,18 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn netbird_configured_launch_url() -> Option<String> {
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
.await
.ok()?;
env.lines()
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| PodmanClient::lan_address_for("netbird"))
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
let url = candidate?;
if !requires_reachable_launch(app_id) {
@@ -673,20 +702,25 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
return None;
};
match tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
Ok(Ok(_)) => Some(url),
_ => {
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
None
}
if launch_port_reachable(port).await {
Some(url)
} else {
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
None
}
}
async fn launch_port_reachable(port: u16) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
fn requires_reachable_launch(app_id: &str) -> bool {
matches!(
app_id,
+61 -25
View File
@@ -8,6 +8,8 @@ use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
use crate::update::host_sudo;
pub const DEFAULT_SRV_ROOT: &str = "/var/lib/archipelago/filebrowser";
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/filebrowser-data";
pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.filebrowser.json";
@@ -39,17 +41,11 @@ pub enum EnsureOutcome {
}
pub async fn ensure_config(paths: &EnsurePaths) -> Result<EnsureOutcome> {
fs::create_dir_all(&paths.srv_root)
.await
.with_context(|| format!("creating {}", paths.srv_root.display()))?;
fs::create_dir_all(&paths.data_dir)
.await
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
create_dir_all_or_sudo(&paths.srv_root).await?;
create_dir_all_or_sudo(&paths.data_dir).await?;
for d in ["Documents", "Photos", "Music", "Downloads", "Builds"] {
fs::create_dir_all(paths.srv_root.join(d))
.await
.with_context(|| format!("creating {}/{}", paths.srv_root.display(), d))?;
create_dir_all_or_sudo(&paths.srv_root.join(d)).await?;
}
if paths.config_path.exists() {
@@ -60,27 +56,67 @@ pub async fn ensure_config(paths: &EnsurePaths) -> Result<EnsureOutcome> {
.config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("config_path has no parent directory"))?;
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
create_dir_all_or_sudo(parent).await?;
let tmp = paths.config_path.with_extension("tmp");
fs::write(&tmp, DEFAULT_CONFIG_JSON)
.await
.with_context(|| format!("writing tmp {}", tmp.display()))?;
fs::rename(&tmp, &paths.config_path)
.await
.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.config_path.display()
)
})?;
write_config_atomically(paths).await?;
Ok(EnsureOutcome::Written)
}
async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> {
match fs::create_dir_all(path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let path = path.to_string_lossy();
let status = host_sudo(&["mkdir", "-p", &path])
.await
.with_context(|| format!("creating {path} via sudo"))?;
if !status.success() {
anyhow::bail!("mkdir -p {path} via sudo exited with {status}");
}
Ok(())
}
Err(e) => Err(e).with_context(|| format!("creating {}", path.display())),
}
}
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
let tmp = paths.config_path.with_extension("tmp");
match fs::write(&tmp, DEFAULT_CONFIG_JSON).await {
Ok(()) => {
fs::rename(&tmp, &paths.config_path)
.await
.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.config_path.display()
)
})?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let script = format!(
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
shell_quote(&paths.config_path.to_string_lossy()),
DEFAULT_CONFIG_JSON
);
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("writing .filebrowser.json via sudo")?;
if !status.success() {
anyhow::bail!("writing .filebrowser.json via sudo exited with {status}");
}
Ok(())
}
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
}
}
fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,7 +168,9 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
"tailscale" => Some("TAILSCALE_IMAGE"),
"netbird" => Some("NETBIRD_IMAGE"),
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
@@ -185,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
@@ -211,6 +210,10 @@ pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
/// explicit versions we should advertise to users as available updates.
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
let pinned = pinned_image_for_app(app_id)?;
available_update_for_images(&pinned, running_image)
}
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(&pinned);
if is_floating_tag(&pinned_version) {
return None;
@@ -300,6 +303,11 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
"netbird" => vec![
("netbird", "NETBIRD_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
("netbird-server", "NETBIRD_SERVER_IMAGE"),
],
_ => vec![],
}
}
@@ -356,6 +364,28 @@ mod tests {
assert!(!is_floating_tag("v0.18.4-beta"));
}
#[test]
fn available_update_ignores_registry_only_changes() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"git.tx1138.com/lfg2025/nextcloud:29",
),
None
);
}
#[test]
fn available_update_returns_pinned_version_for_same_repo_newer_tag() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"146.59.87.168:3000/lfg2025/nextcloud:28",
),
Some("29".to_string())
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
+2 -1
View File
@@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
if file_exists_as_root(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
@@ -305,6 +305,7 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
#[allow(dead_code)]
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
File diff suppressed because it is too large Load Diff
+260 -33
View File
@@ -34,9 +34,13 @@ use anyhow::{anyhow, Context, Result};
use archipelago_container::AppManifest;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;
use tokio::process::Command;
const QUADLET_START_TIMEOUT: Duration = Duration::from_secs(90);
const QUADLET_STOP_TIMEOUT: Duration = Duration::from_secs(45);
/// Default rootless quadlet directory. Resolved per-user at runtime via
/// `unit_dir()`. Tests pass an explicit dir.
pub const DEFAULT_REL_UNIT_DIR: &str = ".config/containers/systemd";
@@ -61,6 +65,12 @@ pub enum NetworkMode {
/// attached to it. The network must already exist (orchestrator's
/// `ensure_container_network` handles that on every reconcile tick).
Bridge(String),
/// Rootless slirp4netns networking. Podman rejects network aliases with
/// this mode, so render only Network=slirp4netns.
Slirp4netns,
/// Rootless pasta networking. This is more reliable than slirp4netns for
/// host port forwarding on long-running web apps.
Pasta,
}
/// systemd Restart= policy for the generated `.service` unit. Companions
@@ -181,6 +191,12 @@ impl QuadletUnit {
NetworkMode::Host => {
let _ = writeln!(s, "Network=host");
}
NetworkMode::Slirp4netns => {
let _ = writeln!(s, "Network=slirp4netns");
}
NetworkMode::Pasta => {
let _ = writeln!(s, "Network=pasta");
}
NetworkMode::Bridge(net) => {
let _ = writeln!(s, "Network={net}");
for alias in &self.network_aliases {
@@ -261,6 +277,13 @@ impl QuadletUnit {
}
let _ = writeln!(s);
let _ = writeln!(s, "[Service]");
// Dependency-gated apps may legitimately keep their container entrypoint
// in a wait loop before the actual daemon binds ports. Fedimint waits
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
// start timeout otherwise kills the generated podman run job and leaves
// the unit stuck in deactivating. Health/status remains app-level state,
// not a systemd start gate.
let _ = writeln!(s, "TimeoutStartSec=0");
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
// from saturating the journal. Companions: Always. Backends:
// OnFailure (clean stops stay stopped).
@@ -334,6 +357,8 @@ impl QuadletUnit {
// either form.
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
_ => match app.container.network.as_deref() {
Some("slirp4netns") => NetworkMode::Slirp4netns,
Some("pasta") => NetworkMode::Pasta,
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
_ => NetworkMode::Default,
},
@@ -382,7 +407,7 @@ impl QuadletUnit {
entrypoint: app.container.entrypoint.clone(),
command: app.container.custom_args.clone(),
read_only_root: app.security.readonly_root,
no_new_privileges: true,
no_new_privileges: app.security.no_new_privileges,
cpu_quota: app.resources.cpu_limit,
restart_policy: RestartPolicy::OnFailure,
}
@@ -436,13 +461,14 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
let path = hc.path.as_deref().unwrap_or("/");
format!("{url}{path}")
};
let helper_timeout = health_timeout_seconds(&hc.timeout);
// Images vary wildly: SearXNG ships wget but no curl, while some
// Node images ship neither. Use whichever probe helper exists and
// skip Podman health if the image has none; host-side lifecycle
// probes still verify reachability.
format!(
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 {0}; else exit 0; fi",
final_url
"if command -v wget >/dev/null 2>&1; then wget -q -T {1} -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m {1} {0}; else exit 0; fi",
final_url, helper_timeout
)
}
"cmd" => hc.endpoint.as_deref()?.to_string(),
@@ -456,6 +482,29 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
})
}
fn health_timeout_seconds(raw: &str) -> u64 {
let trimmed = raw.trim();
if trimmed.is_empty() {
return 5;
}
let (number, multiplier) = match trimmed.chars().last() {
Some('s') | Some('S') => (&trimmed[..trimmed.len() - 1], 1),
Some('m') | Some('M') => (&trimmed[..trimmed.len() - 1], 60),
Some('h') | Some('H') => (&trimmed[..trimmed.len() - 1], 3600),
Some(c) if c.is_ascii_digit() => (trimmed, 1),
_ => return 5,
};
number
.trim()
.parse::<u64>()
.ok()
.and_then(|n| n.checked_mul(multiplier))
.filter(|n| *n > 0)
.unwrap_or(5)
}
/// Parse the manifest's memory_limit string into MiB. Recognises the
/// forms our manifests actually use: "<n>", "<n>m"/"<n>M", "<n>g"/"<n>G".
/// Returns None for anything else; the caller treats None as unlimited.
@@ -532,12 +581,21 @@ pub async fn enable_now(service: &str) -> Result<()> {
// .service file lives under /run, not /etc — `enable` would refuse
// ("transient or generated"). The unit's `[Install] WantedBy` is
// honoured at daemon-reload, so we just start it.
let status = Command::new("systemctl")
.args(["--user", "start", service])
.status()
let status = systemctl_user_status(&["start", service], QUADLET_START_TIMEOUT)
.await
.with_context(|| format!("spawn systemctl --user start {service}"))?;
.with_context(|| format!("systemctl --user start {service}"))?;
if !status.success() {
if wait_not_deactivating(service, Duration::from_secs(30)).await {
let retry = systemctl_user_status(&["start", service], QUADLET_START_TIMEOUT)
.await
.with_context(|| format!("retry systemctl --user start {service}"))?;
if retry.success() {
return Ok(());
}
return Err(anyhow!(
"systemctl --user start {service} exited {status}; retry exited {retry}"
));
}
return Err(anyhow!("systemctl --user start {service} exited {status}"));
}
Ok(())
@@ -545,32 +603,112 @@ pub async fn enable_now(service: &str) -> Result<()> {
/// Restart a generated Quadlet service after rewriting a known-bad unit.
pub async fn restart_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "restart", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user restart {service}"))?;
if !status.success() {
// `systemctl restart` hides the stop phase. On rootless Podman nodes a
// generated unit can sit in deactivating while `podman rm -f` hangs, which
// makes RPC/UI state look frozen. Split restart into bounded stop + start
// so stop timeouts can be recovered with an app-scoped kill/reset.
if let Err(err) = stop_service(service).await {
tracing::warn!(
service = %service,
error = %err,
"quadlet stop failed during restart; waiting for unit to settle before start"
);
}
if !wait_not_deactivating(service, Duration::from_secs(120)).await {
return Err(anyhow!(
"systemctl --user restart {service} exited {status}"
"systemctl --user restart {service} could not leave deactivating state"
));
}
Ok(())
enable_now(service).await
}
/// Stop a generated Quadlet service without removing its unit file.
pub async fn stop_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "stop", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user stop {service}"))?;
if !status.success() {
return Err(anyhow!("systemctl --user stop {service} exited {status}"));
match systemctl_user_status(&["stop", service], QUADLET_STOP_TIMEOUT).await {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
Err(err) => {
tracing::warn!(
service = %service,
error = %err,
"quadlet stop timed out/failed; killing app-scoped unit"
);
kill_and_reset_service(service).await?;
if !wait_not_deactivating(service, Duration::from_secs(60)).await {
return Err(anyhow!(
"systemctl --user stop {service} remained deactivating after app-scoped kill"
));
}
Ok(())
}
}
}
async fn systemctl_user_status(
args: &[&str],
timeout: Duration,
) -> Result<std::process::ExitStatus> {
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.status())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
async fn kill_and_reset_service(service: &str) -> Result<()> {
let _ = systemctl_user_status(
&["kill", "--kill-whom=all", "-s", "SIGKILL", service],
Duration::from_secs(15),
)
.await;
tokio::time::sleep(Duration::from_secs(2)).await;
let _ = systemctl_user_status(&["reset-failed", service], Duration::from_secs(15)).await;
Ok(())
}
async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let Ok(status) =
systemctl_user_output(&["is-active", service], Duration::from_secs(5)).await
else {
return true;
};
let state = String::from_utf8_lossy(&status.stdout).trim().to_string();
if state != "deactivating" && state != "activating" {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| {
format!(
"systemctl --user {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
unit_body.contains("Notify=healthy")
|| unit_body.contains("TimeoutStartSec=600")
@@ -579,6 +717,12 @@ pub fn contains_stale_health_gate(unit_body: &str) -> bool {
pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
directive_values(old_body, "HealthCmd=") != directive_values(new_body, "HealthCmd=")
|| directive_values(old_body, "HealthInterval=")
!= directive_values(new_body, "HealthInterval=")
|| directive_values(old_body, "HealthTimeout=")
!= directive_values(new_body, "HealthTimeout=")
|| directive_values(old_body, "HealthRetries=")
!= directive_values(new_body, "HealthRetries=")
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
@@ -588,9 +732,11 @@ pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
let old_network = directive_values(old_body, "Network=");
let new_network = directive_values(new_body, "Network=");
let old_aliases = directive_values(old_body, "NetworkAlias=");
let new_aliases = directive_values(new_body, "NetworkAlias=");
old_aliases != new_aliases
old_network != new_network || old_aliases != new_aliases
}
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
@@ -620,9 +766,11 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
.await;
let path = dir.join(format!("{unit_name}.container"));
if fs::try_exists(&path).await.unwrap_or(false) {
fs::remove_file(&path)
.await
.with_context(|| format!("remove {}", path.display()))?;
match fs::remove_file(&path).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err).with_context(|| format!("remove {}", path.display())),
}
}
daemon_reload_user().await.ok();
// Defensive: kill the actual container too, in case quadlet left it.
@@ -652,7 +800,7 @@ mod tests {
QuadletUnit {
name: "archy-bitcoin-ui".into(),
description: "Bitcoin RPC UI proxy".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:latest".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
network: NetworkMode::Host,
user: Some("0:0".into()),
memory_mb: Some(128),
@@ -680,7 +828,7 @@ mod tests {
let s = sample_unit().render();
assert!(s.contains("[Container]"));
assert!(s.contains("ContainerName=archy-bitcoin-ui"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:latest"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha"));
assert!(s.contains("Pull=never"));
assert!(s.contains("Network=host"));
assert!(s.contains("DropCapability=ALL"));
@@ -957,6 +1105,48 @@ app:
assert!(!s.contains("Network=host"));
}
#[test]
fn from_manifest_slirp4netns_omits_network_alias() {
let yaml = r#"
app:
id: vaultwarden
name: Vaultwarden
version: 1.0.0
container:
image: registry/vaultwarden:1
network: slirp4netns
security:
network_policy: isolated
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "vaultwarden").render();
assert!(s.contains("Network=slirp4netns"));
assert!(!s.contains("NetworkAlias="));
assert!(!s.contains("--network-alias"));
}
#[test]
fn from_manifest_pasta_omits_network_alias() {
let yaml = r#"
app:
id: nextcloud
name: Nextcloud
version: 1.0.0
container:
image: registry/nextcloud:1
network: pasta
security:
network_policy: isolated
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "nextcloud").render();
assert!(s.contains("Network=pasta"));
assert!(!s.contains("NetworkAlias="));
assert!(!s.contains("--network-alias"));
}
#[test]
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
let yaml = r#"
@@ -1056,18 +1246,20 @@ app:
assert!(s.contains("HealthRetries=3"));
assert!(!s.contains("Notify=healthy"));
assert!(!s.contains("TimeoutStartSec=600"));
assert!(s.contains("TimeoutStartSec=0"));
}
#[test]
fn render_skips_health_directives_when_absent() {
// No health spec → no Notify=healthy, no HealthCmd, no TimeoutStartSec
// override. Companions rely on this so their rendered bytes stay
// unchanged.
// No health spec → no Notify=healthy and no HealthCmd. TimeoutStartSec=0
// is a service-level baseline so dependency-waiting apps are not killed
// by systemd before their app daemon binds.
let s = sample_unit().render();
assert!(!s.contains("HealthCmd="));
assert!(!s.contains("Notify=healthy"));
assert!(!s.contains("HealthRetries="));
assert!(!s.contains("TimeoutStartSec="));
assert!(s.contains("TimeoutStartSec=0"));
assert!(!s.contains("TimeoutStartSec=600"));
}
#[test]
@@ -1094,7 +1286,7 @@ app:
let h = translate_health_check(&http).expect("http must translate");
assert_eq!(
h.cmd,
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/health; else exit 0; fi"
"if command -v wget >/dev/null 2>&1; then wget -q -T 3 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 3 http://localhost:8080/health; else exit 0; fi"
);
let cmdck = HealthCheck {
@@ -1163,6 +1355,25 @@ app:
assert!(h.cmd.contains("https://example.local/health"));
}
#[test]
fn translate_health_check_http_uses_manifest_timeout_for_helpers() {
use archipelago_container::HealthCheck;
let http = HealthCheck {
check_type: "http".into(),
endpoint: Some("localhost:3000".into()),
path: Some("/api/health".into()),
interval: "30s".into(),
timeout: "30s".into(),
retries: 5,
};
let h = translate_health_check(&http).expect("http must translate");
assert!(h.cmd.contains("wget -q -T 30 "), "got: {}", h.cmd);
assert!(h.cmd.contains("curl -fsS -m 30 "), "got: {}", h.cmd);
assert_eq!(h.timeout, "30s");
assert_eq!(h.retries, 5);
}
#[test]
fn from_manifest_picks_up_health_check() {
let yaml = r#"
@@ -1201,6 +1412,14 @@ app:
assert!(!network_aliases_changed(new, new));
}
#[test]
fn network_aliases_changed_detects_network_mode_drift() {
let old = "[Container]\nNetwork=slirp4netns\n";
let new = "[Container]\n";
assert!(network_aliases_changed(old, new));
assert!(!network_aliases_changed(new, new));
}
#[test]
fn shell_join_escapes_dollars_for_container_runtime_expansion() {
let rendered = shell_join(&["sh".into(), "-lc".into(), "echo ${BITCOIN_RPC_PASS}".into()]);
@@ -1223,6 +1442,14 @@ app:
assert!(!health_cmd_changed(new, new));
}
#[test]
fn health_cmd_changed_detects_probe_timing_drift() {
let old = "[Container]\nHealthCmd=curl -fsS http://localhost:8080/\nHealthTimeout=5s\nHealthRetries=3\n";
let new = "[Container]\nHealthCmd=curl -fsS http://localhost:8080/\nHealthTimeout=30s\nHealthRetries=5\n";
assert!(health_cmd_changed(old, new));
assert!(!health_cmd_changed(new, new));
}
#[test]
fn from_manifest_renders_to_a_systemd_unit() {
// End-to-end: parse a real-shape manifest, build the unit, render
+342 -8
View File
@@ -334,6 +334,103 @@ fn is_process_running(pid: u32) -> bool {
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
/// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
start_stopped_containers_for(data_dir, false).await
}
/// Start stopped multi-container stack members after the backend is already
/// ready. These can take minutes after a reboot, so they must not block
/// systemd readiness.
pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
start_stopped_app_stacks(data_dir).await
}
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
let user_stopped = load_user_stopped(data_dir).await;
let mut report = RecoveryReport {
total: 0,
recovered: 0,
failed: Vec::new(),
};
for stack in stack_recovery_specs() {
if !stack_has_any_container(stack).await {
continue;
}
info!(
"Recovering stopped {} stack containers after boot",
stack.name
);
repair_stack_network_aliases(stack).await;
for container in stack.containers {
if user_stopped.contains(*container) {
info!("Skipping user-stopped container: {}", container);
continue;
}
match container_state(container).await {
Some(state) if state == "running" => continue,
Some(_) => {}
None => continue,
}
repair_stack_network_aliases(stack).await;
wait_before_stack_container_recovery(stack, container).await;
report.total += 1;
if start_existing_container(container).await {
report.recovered += 1;
} else {
report.failed.push((*container).to_string());
}
}
}
report
}
async fn wait_before_stack_container_recovery(stack: &StackRecoverySpec, container: &str) {
if stack.name != "indeedhub" || container != "indeedhub" {
return;
}
for _ in 0..60 {
if indeedhub_recovery_dependencies_running().await {
repair_stack_network_aliases(stack).await;
break;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
for _ in 0..60 {
let ready = podman_output(
&["exec", "indeedhub-api", "getent", "hosts", "minio"],
Duration::from_secs(5),
)
.await
.map(|output| output.status.success())
.unwrap_or(false);
if ready {
return;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn indeedhub_recovery_dependencies_running() -> bool {
for name in ["indeedhub-redis", "indeedhub-minio", "indeedhub-api"] {
if container_state(name).await.as_deref() != Some("running") {
return false;
}
}
true
}
async fn start_stopped_containers_for(
data_dir: &Path,
include_stack_members: bool,
) -> RecoveryReport {
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
@@ -400,7 +497,7 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let names: Vec<String> = names
.into_iter()
.filter(|n| should_auto_start_stopped_container(n))
.filter(|n| should_auto_start_stopped_container(n, include_stack_members))
.collect();
if names.is_empty() {
@@ -429,11 +526,241 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
recover_containers(&records).await
}
fn should_auto_start_stopped_container(name: &str) -> bool {
fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) -> bool {
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
// managed app stacks; starting every exited Podman container here races
// it and resurrects legacy/orphan helper containers.
matches!(name, "filebrowser" | "nostr-rs-relay")
if matches!(name, "filebrowser" | "nostr-rs-relay") {
return true;
}
include_stack_members
&& matches!(
name,
"immich_postgres"
| "immich_redis"
| "immich_server"
| "indeedhub-postgres"
| "indeedhub-redis"
| "indeedhub-minio"
| "indeedhub-relay"
| "indeedhub-api"
| "indeedhub-ffmpeg"
| "indeedhub"
| "netbird-server"
| "netbird-dashboard"
| "netbird"
)
}
struct StackRecoverySpec {
name: &'static str,
network: &'static str,
aliases: &'static [(&'static str, &'static str)],
containers: &'static [&'static str],
}
fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
&[
StackRecoverySpec {
name: "immich",
network: "immich-net",
aliases: &[
("immich_postgres", "immich_postgres"),
("immich_redis", "immich_redis"),
("immich_server", "immich_server"),
],
containers: &["immich_postgres", "immich_redis", "immich_server"],
},
StackRecoverySpec {
name: "indeedhub",
network: "indeedhub-net",
aliases: &[
("indeedhub-postgres", "postgres"),
("indeedhub-redis", "redis"),
("indeedhub-minio", "minio"),
("indeedhub-relay", "relay"),
("indeedhub-api", "api"),
("indeedhub", "indeedhub"),
],
containers: &[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
},
StackRecoverySpec {
name: "netbird",
network: "netbird-net",
aliases: &[
("netbird-server", "netbird-server"),
("netbird-dashboard", "netbird-dashboard"),
("netbird", "netbird"),
],
containers: &["netbird-server", "netbird-dashboard", "netbird"],
},
]
}
async fn stack_has_any_container(stack: &StackRecoverySpec) -> bool {
for container in stack.containers {
if container_state(container).await.is_some() {
return true;
}
}
false
}
async fn repair_stack_network_aliases(stack: &StackRecoverySpec) {
let _ = podman_status(
&["network", "create", stack.network],
Duration::from_secs(15),
)
.await;
for (container, alias) in stack.aliases {
if container_state(container).await.is_none() {
continue;
}
if network_alias_present(stack.network, container, alias).await {
continue;
}
let _ = podman_status(
&["network", "disconnect", "-f", stack.network, container],
Duration::from_secs(15),
)
.await;
let _ = podman_status(
&[
"network",
"connect",
"--alias",
alias,
stack.network,
container,
],
Duration::from_secs(15),
)
.await;
}
}
async fn network_alias_present(network_name: &str, container: &str, alias: &str) -> bool {
let output = match podman_output(
&[
"inspect",
container,
"--format",
"{{json .NetworkSettings.Networks}}",
],
Duration::from_secs(10),
)
.await
{
Ok(output) if output.status.success() => output,
_ => return false,
};
let Ok(networks) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return false;
};
networks
.get(network_name)
.and_then(|network| network.get("Aliases"))
.and_then(|aliases| aliases.as_array())
.map(|aliases| aliases.iter().any(|value| value.as_str() == Some(alias)))
.unwrap_or(false)
}
async fn container_state(container: &str) -> Option<String> {
let output = podman_output(
&["inspect", container, "--format", "{{.State.Status}}"],
Duration::from_secs(10),
)
.await
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn start_existing_container(container: &str) -> bool {
info!("Recovering stack container: {}", container);
let timeout = match container {
"immich_server" | "netbird-server" => Duration::from_secs(120),
_ => Duration::from_secs(90),
};
if container_state(container).await.as_deref() == Some("initialized") {
cleanup_container_runtime_state(container).await;
}
match podman_output(&["start", container], timeout).await {
Ok(output) if output.status.success() => {
tokio::time::sleep(Duration::from_secs(3)).await;
if container_state(container).await.as_deref() == Some("exited") {
warn!("Stack container {} exited shortly after start", container);
false
} else {
info!("Successfully recovered stack container: {}", container);
true
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.contains("exec.fifo") || stderr.contains("failed to start container") {
cleanup_container_runtime_state(container).await;
if let Ok(retry) = podman_output(&["start", container], timeout).await {
if retry.status.success() {
info!(
"Successfully recovered stack container after cleanup: {}",
container
);
return true;
}
warn!(
"Failed to recover stack container {} after cleanup: {}",
container,
String::from_utf8_lossy(&retry.stderr).trim()
);
return false;
}
}
warn!(
"Failed to recover stack container {}: {}",
container, stderr
);
false
}
Err(e) => {
warn!("Failed to recover stack container {}: {}", container, e);
false
}
}
}
async fn cleanup_container_runtime_state(container: &str) {
let _ = podman_output(
&["container", "cleanup", container],
Duration::from_secs(30),
)
.await;
}
async fn podman_status(args: &[&str], timeout: Duration) -> Option<std::process::ExitStatus> {
podman_output(args, timeout)
.await
.ok()
.map(|output| output.status)
}
async fn podman_output(args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(args);
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
}
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
@@ -620,10 +947,17 @@ mod tests {
#[test]
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
assert!(should_auto_start_stopped_container("filebrowser"));
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
assert!(!should_auto_start_stopped_container("lnd"));
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
assert!(should_auto_start_stopped_container("filebrowser", false));
assert!(should_auto_start_stopped_container("nostr-rs-relay", false));
assert!(!should_auto_start_stopped_container("bitcoin-knots", false));
assert!(!should_auto_start_stopped_container("lnd", false));
assert!(!should_auto_start_stopped_container(
"indeedhub-postgres",
false
));
assert!(should_auto_start_stopped_container(
"indeedhub-postgres",
true
));
}
}
+10 -2
View File
@@ -17,7 +17,8 @@ const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
const ESTIMATED_FULL_INDEX_BYTES: f64 = 130_000_000_000.0;
/// Refresh interval for status cache
const CACHE_REFRESH_SECS: u64 = 15;
const CACHE_REFRESH_SECS: u64 = 30;
const CACHE_ERROR_BACKOFF_SECS: u64 = 60;
/// Build Bitcoin RPC Basic auth header using shared credentials.
async fn bitcoin_rpc_auth() -> String {
@@ -70,6 +71,11 @@ pub fn spawn_status_cache() {
tokio::spawn(async {
loop {
let mut fresh = fetch_electrs_sync_status().await;
let sleep_secs = if fresh.status == "waiting" && fresh.bitcoin_height == 0 {
CACHE_ERROR_BACKOFF_SECS
} else {
CACHE_REFRESH_SECS
};
let mut cached = cache().write().await;
if fresh.indexed_height == 0
&& cached.indexed_height > 0
@@ -92,7 +98,7 @@ pub fn spawn_status_cache() {
}
*cached = fresh;
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
}
});
}
@@ -146,6 +152,8 @@ fn is_transient_error(err_msg: &str) -> bool {
|| lower.contains("broken pipe")
|| lower.contains("eof")
|| lower.contains("connection")
|| lower.contains("503 service unavailable")
|| lower.contains("work queue depth exceeded")
}
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
+72 -3
View File
@@ -66,7 +66,9 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
@@ -109,6 +111,7 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
@@ -119,8 +122,9 @@ async fn merge_transitive_peers(
let mut refreshed = 0u32;
for hint in hints {
// Don't import our own DID (a peer advertising us back).
if hint.did == source_did {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
@@ -359,4 +363,69 @@ mod tests {
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("trusted transitive peer should be added");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
assert_eq!(peer.trust_level, TrustLevel::Trusted);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}
+116 -44
View File
@@ -217,6 +217,7 @@ struct ContainerHealth {
app_id: String,
state: String,
podman_health: Option<String>,
host_port_ready: Option<bool>,
healthy: bool,
}
@@ -427,42 +428,92 @@ async fn check_containers() -> Vec<ContainerHealth> {
// nbxplorer, mempool-api) and UI containers need auto-restart too.
// Only skip ephemeral containers (build infrastructure, init one-shots).
containers
.iter()
.filter_map(|c| {
let name = c.get("Names").and_then(|v| {
if let Some(arr) = v.as_array() {
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
})?;
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
return None;
let mut out = Vec::new();
for c in &containers {
let name = c.get("Names").and_then(|v| {
if let Some(arr) = v.as_array() {
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
});
let Some(name) = name else {
continue;
};
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
continue;
}
let state = c
.get("State")
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
let state = c
.get("State")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_lowercase();
let podman_health = parse_podman_health(c, &state);
let host_ports = host_tcp_ports_from_container(c);
let host_port_ready = if host_ports.is_empty() {
None
} else {
Some(host_ports_ready(&host_ports).await)
};
let healthy = state == "running"
&& podman_health.as_deref() != Some("unhealthy")
&& host_port_ready != Some(false);
out.push(ContainerHealth {
name,
app_id,
state,
podman_health,
host_port_ready,
healthy,
});
}
out
}
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out: Vec<u16> = ports
.iter()
.filter(|p| {
p.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_lowercase();
let podman_health = parse_podman_health(c, &state);
let healthy = state == "running" && podman_health.as_deref() != Some("unhealthy");
Some(ContainerHealth {
name,
app_id,
state,
podman_health,
healthy,
})
.unwrap_or("tcp")
.eq_ignore_ascii_case("tcp")
})
.collect()
.filter_map(|p| {
p.get("host_port")
.and_then(|v| v.as_u64())
.and_then(|port| u16::try_from(port).ok())
})
.collect();
out.sort_unstable();
out.dedup();
out
}
async fn host_ports_ready(ports: &[u16]) -> bool {
for port in ports {
let ready = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
)
.await
.is_ok_and(|r| r.is_ok());
if !ready {
return false;
}
}
true
}
fn live_container_ids(containers: &[serde_json::Value]) -> HashSet<String> {
@@ -640,33 +691,41 @@ fn parse_health_from_status(status: &str) -> Option<String> {
(start < end).then(|| status[start + 1..end].to_string())
}
/// Try to restart a container.
async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name);
/// Try to recover a container. Running containers need a real restart so
/// rootless network helpers such as pasta are recreated; `podman start` is a
/// no-op for a running container with a missing host listener.
async fn restart_container(name: &str, state: &str) -> bool {
let action = if state == "running" {
"restart"
} else {
"start"
};
info!("Auto-{}ing unhealthy container: {}", action, name);
let result = tokio::time::timeout(
std::time::Duration::from_secs(120),
tokio::process::Command::new("podman")
.args(["start", name])
tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args([action, name])
.output(),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
info!("Successfully restarted container: {}", name);
info!("Successfully recovered container: {}", name);
true
}
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to restart container {}: {}", name, stderr.trim());
warn!("Failed to {} container {}: {}", action, name, stderr.trim());
false
}
Ok(Err(e)) => {
warn!("Failed to execute podman start for {}: {}", name, e);
warn!("Failed to execute podman {} for {}: {}", action, name, e);
false
}
Err(_) => {
warn!("Timeout starting container {} (120s)", name);
warn!("Timeout {}ing container {} (120s)", action, name);
false
}
}
@@ -684,9 +743,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
if crate::crash_recovery::is_recovery_complete() {
break;
}
// Safety timeout: start anyway after 5 minutes even if recovery hangs
if wait_start.elapsed().as_secs() > 300 {
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
// Safety timeout: start anyway after 30 minutes even if recovery hangs.
// Stack recovery can take many minutes on low-resource nodes after reboot.
if wait_start.elapsed().as_secs() > 1800 {
warn!("Health monitor: boot recovery did not complete within 30 minutes, starting anyway");
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
@@ -827,6 +887,7 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
// Handle exited, stopped, created, and Podman-unhealthy running containers.
if container.podman_health.as_deref() == Some("unhealthy")
|| container.host_port_ready == Some(false)
|| container.state == "exited"
|| container.state == "stopped"
|| container.state == "created"
@@ -932,7 +993,7 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
.unwrap_or(&90)
);
let restarted = restart_container(&container.name).await;
let restarted = restart_container(&container.name, &container.state).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
let notification = Notification {
@@ -1088,6 +1149,7 @@ mod tests {
app_id: "bitcoin-knots".to_string(),
state: "running".to_string(),
podman_health: Some("healthy".to_string()),
host_port_ready: None,
healthy: true,
};
assert!(health.healthy);
@@ -1103,6 +1165,7 @@ mod tests {
app_id: "mempool-web".to_string(),
state: "exited".to_string(),
podman_health: None,
host_port_ready: None,
healthy: false,
};
assert!(!health.healthy);
@@ -1193,6 +1256,7 @@ mod tests {
app_id: "indeedhub-postgres".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
},
ContainerHealth {
@@ -1200,6 +1264,7 @@ mod tests {
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
},
ContainerHealth {
@@ -1207,6 +1272,7 @@ mod tests {
app_id: "indeedhub-api".into(),
state: "exited".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
},
];
@@ -1217,6 +1283,7 @@ mod tests {
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(!deps_are_running("indeedhub-api", &partial));
@@ -1229,6 +1296,7 @@ mod tests {
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
@@ -1238,6 +1306,7 @@ mod tests {
app_id: "bitcoin-knots".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
@@ -1247,6 +1316,7 @@ mod tests {
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
@@ -1259,6 +1329,7 @@ mod tests {
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
@@ -1274,6 +1345,7 @@ mod tests {
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
}];
+24 -5
View File
@@ -20,6 +20,7 @@
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::signal;
use tokio::sync::Notify;
use tracing::info;
@@ -168,8 +169,6 @@ async fn main() -> Result<()> {
boot_report.recovered, boot_report.total, boot_report.failed
);
}
crash_recovery::mark_recovery_complete();
// Construct the container orchestrator once. In prod mode we load the
// on-disk app manifests, do an initial adoption pass, and spawn the
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
@@ -195,17 +194,20 @@ async fn main() -> Result<()> {
}
// Adoption pass: link existing podman containers back to their
// manifests so the reconciler doesn't recreate them.
match prod.adopt_existing().await {
Ok(report) => {
match tokio::time::timeout(Duration::from_secs(35), prod.adopt_existing()).await {
Ok(Ok(report)) => {
info!(
"🔗 Adopted {} existing container(s): {:?}",
report.adopted.len(),
report.adopted
);
}
Err(e) => {
Ok(Err(e)) => {
tracing::warn!(error = %e, "prod orchestrator: adopt_existing failed (non-fatal)");
}
Err(_) => {
tracing::warn!("prod orchestrator: adopt_existing timed out after 35s (non-fatal)")
}
}
// Spawn the boot reconciler loop. Runs an initial reconcile
// immediately, then re-checks every RECONCILER_DEFAULT_INTERVAL
@@ -272,6 +274,23 @@ async fn main() -> Result<()> {
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
// Recover stopped multi-container stack members after the backend is up.
// This can take minutes on busy nodes after a reboot, so keep it out of
// the synchronous systemd startup path.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
if report.total > 0 {
info!(
"🔄 Stack boot recovery: {}/{} containers started (failed: {:?})",
report.recovered, report.total, report.failed
);
}
crash_recovery::mark_recovery_complete();
});
}
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
@@ -1,15 +1,20 @@
use crate::monitoring::types::{AlertRuleKind, FiredAlert};
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use chrono::Utc;
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use tracing::info;
const NOTIFICATION_MAX_AGE_SECS: i64 = 30 * 60;
/// Push fired alerts as notifications to the state manager (broadcast via WebSocket).
pub(crate) async fn push_alert_notifications(
state_mgr: &Arc<crate::state::StateManager>,
alerts: &[FiredAlert],
) {
let (mut data, _rev) = state_mgr.get_snapshot().await;
prune_stale_alert_notifications(&mut data.notifications, alerts);
for alert in alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
@@ -27,7 +32,7 @@ pub(crate) async fn push_alert_notifications(
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
timestamp: Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
@@ -40,6 +45,30 @@ pub(crate) async fn push_alert_notifications(
info!("Fired {} alert(s)", alerts.len());
}
fn prune_stale_alert_notifications(
notifications: &mut Vec<crate::data_model::Notification>,
alerts: &[FiredAlert],
) {
let now = Utc::now();
let active_ids: HashSet<&str> = alerts.iter().map(|alert| alert.id.as_str()).collect();
notifications.retain(|notification| {
if active_ids.contains(notification.id.as_str()) {
return false;
}
if notification.app_id.is_some() || notification.id.starts_with("health-") {
return true;
}
match chrono::DateTime::parse_from_rfc3339(&notification.timestamp) {
Ok(ts) => {
now.signed_duration_since(ts.with_timezone(&Utc))
.num_seconds()
<= NOTIFICATION_MAX_AGE_SECS
}
Err(_) => false,
}
});
}
/// Deliver webhook notifications for alerts that map to webhook events.
pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert]) {
for alert in alerts {
@@ -53,7 +82,7 @@ pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
timestamp: Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
@@ -64,3 +93,46 @@ pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::{Notification, NotificationLevel};
fn notification(id: &str, timestamp: String, app_id: Option<&str>) -> Notification {
Notification {
id: id.to_string(),
level: NotificationLevel::Warning,
title: "DiskUsage Alert".to_string(),
message: "Disk warning".to_string(),
timestamp,
app_id: app_id.map(str::to_string),
}
}
#[test]
fn prune_stale_alert_notifications_removes_duplicate_and_old_generic_alerts() {
let active_alert = FiredAlert {
id: "alert-active".to_string(),
kind: AlertRuleKind::DiskUsage,
message: "Disk warning".to_string(),
value: 90.0,
threshold: 85.0,
timestamp: Utc::now().timestamp(),
acknowledged: false,
};
let old_timestamp = (Utc::now() - chrono::Duration::minutes(45)).to_rfc3339();
let fresh_timestamp = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
let mut notifications = vec![
notification("alert-active", fresh_timestamp.clone(), None),
notification("alert-old", old_timestamp, None),
notification("alert-fresh", fresh_timestamp.clone(), None),
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
];
prune_stale_alert_notifications(&mut notifications, &[active_alert]);
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
}
}
+95 -24
View File
@@ -71,30 +71,54 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state
{
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let containers: Vec<serde_json::Value> = data
.package_data
.iter()
.map(|(id, pkg)| {
serde_json::json!({
"id": id,
"state": format!("{:?}", pkg.state),
"version": pkg.manifest.version,
})
})
.collect();
let running = data
.package_data
.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(
id,
data.server_info
.name
.clone()
.filter(|n| !n.trim().is_empty()),
data.server_info.version.clone(),
data.package_data.len(),
running,
data.peer_health.len(),
containers,
)
} else {
(
"unknown".to_string(),
None,
"unknown".to_string(),
0,
0,
0,
Vec::new(),
)
};
let running = data
.package_data
.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(
id,
data.server_info.version.clone(),
data.package_data.len(),
running,
data.peer_health.len(),
)
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism()
@@ -106,6 +130,8 @@ async fn build_telemetry_report(
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
let hostname = system_hostname().await;
let server_url = local_server_url(data_dir).await;
// Latest metrics snapshot
let latest = store.latest().await;
@@ -147,12 +173,16 @@ async fn build_telemetry_report(
Ok(serde_json::json!({
"node_id": node_id,
"node_name": node_name,
"hostname": hostname,
"server_url": server_url,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"containers": containers,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
@@ -161,21 +191,62 @@ async fn build_telemetry_report(
}))
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
let _ = data_dir;
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
.to_string();
Some(format!("https://{ip}"))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let payload = serde_json::json!({
"method": "telemetry.ingest",
"params": report,
});
let response = client
.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
let status = response.status();
let body: serde_json::Value = response.json().await.unwrap_or_default();
if let Some(error) = body.get("error") {
anyhow::bail!("Collector RPC error: {}", error);
}
if body.get("result").is_none() {
anyhow::bail!("Collector returned {} without RPC result", status);
}
Ok(())
}
+217 -17
View File
@@ -15,8 +15,10 @@ use hyper::server::conn::Http;
use hyper::service::service_fn;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tracing::{debug, error, info, warn};
@@ -27,6 +29,25 @@ pub struct Server {
_state_manager: Arc<StateManager>,
}
struct ContainerScanGuard<'a> {
scanning: &'a AtomicBool,
}
impl<'a> ContainerScanGuard<'a> {
fn try_acquire(scanning: &'a AtomicBool) -> Option<Self> {
scanning
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.ok()
.map(|_| Self { scanning })
}
}
impl Drop for ContainerScanGuard<'_> {
fn drop(&mut self) {
self.scanning.store(false, Ordering::Release);
}
}
impl Server {
pub async fn new(
config: Config,
@@ -331,6 +352,7 @@ impl Server {
// lifecycle op, and to break out if the spawned task dies
// without ever writing a final state.
let mut transitional_since: HashMap<String, Instant> = HashMap::new();
let mut scan_backoff_until: Option<Instant> = None;
if let Err(e) = scan_and_update_packages(
&scanner,
&state,
@@ -342,6 +364,10 @@ impl Server {
.await
{
error!("Failed to scan containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
warn!("Podman container scan timed out; backing off scans for 30s");
}
}
// Bump the scan-completion counter so any caller waiting on a
// kicked scan (install/update success path) can proceed.
@@ -356,7 +382,7 @@ impl Server {
// Skip missed ticks instead of catching up — prevents burst of scans
// after a slow podman response (which causes DB lock storms)
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let scanning = std::sync::Arc::new(AtomicBool::new(false));
loop {
tokio::select! {
_ = interval.tick() => {}
@@ -364,12 +390,19 @@ impl Server {
debug!("Scan kicked by install/update success — running immediately");
}
}
if scanning.load(std::sync::atomic::Ordering::Relaxed) {
debug!("Skipping container scan — previous scan still in progress");
continue;
if let Some(until) = scan_backoff_until {
if Instant::now() < until {
debug!("Skipping container scan — Podman scan backoff active");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
}
}
scanning.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = scan_and_update_packages(
let Some(_scan_guard) = ContainerScanGuard::try_acquire(&scanning) else {
debug!("Skipping container scan — previous scan still in progress");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
};
let scan_result = scan_and_update_packages(
&scanner,
&state,
identity_clone.as_ref(),
@@ -377,12 +410,17 @@ impl Server {
&mut absence_tracker,
&mut transitional_since,
)
.await
{
.await;
if let Err(e) = scan_result {
error!("Failed to update containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
warn!("Podman container scan timed out; backing off scans for 30s");
}
} else {
scan_backoff_until = None;
}
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
scanning.store(false, std::sync::atomic::Ordering::Relaxed);
}
});
}
@@ -847,10 +885,10 @@ const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
if *state == crate::data_model::PackageState::Installing {
INSTALLING_STUCK_TIMEOUT
} else {
TRANSITIONAL_STUCK_TIMEOUT
use crate::data_model::PackageState::*;
match state {
Installing | Starting | Restarting => INSTALLING_STUCK_TIMEOUT,
_ => TRANSITIONAL_STUCK_TIMEOUT,
}
}
@@ -874,6 +912,18 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
)
}
fn absent_transitional_replacement(
state: &crate::data_model::PackageState,
) -> Option<crate::data_model::PackageState> {
match state {
// A stop operation is complete once the container record disappears.
// Do not leave the app card wedged in "Stopping..." just because the
// background task died or the backend restarted before it wrote back.
crate::data_model::PackageState::Stopping => Some(crate::data_model::PackageState::Stopped),
_ => None,
}
}
/// Merge a fresh scan entry `fresh` into `existing` while preserving
/// `existing.state` (which is transitional — the RPC spawn task owns it).
/// Non-state observability fields are taken from `fresh` so the UI still
@@ -881,8 +931,17 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
fn merge_preserving_transitional(
existing: &crate::data_model::PackageDataEntry,
fresh: &crate::data_model::PackageDataEntry,
user_stop_requested: bool,
) -> crate::data_model::PackageDataEntry {
let state = match (&existing.state, &fresh.state) {
// A user-initiated stop must keep showing Stopping while podman still
// reports Running. Repair/restart transitions do not have a user-stop
// marker, so a fresh Running scan means the app recovered.
(crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Running)
if !user_stop_requested =>
{
fresh.state.clone()
}
// Removing with a live running container is stale: uninstall either
// failed or Archipelago restarted before the spawned task could revert
// state. Let the scanner recover the UI immediately instead of
@@ -909,6 +968,11 @@ fn merge_preserving_transitional(
}
}
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
let msg = format!("{:#}", error);
msg.contains("podman ps") && msg.contains("timed out")
}
async fn scan_and_update_packages(
scanner: &DockerPackageScanner,
state: &StateManager,
@@ -925,6 +989,7 @@ async fn scan_and_update_packages(
pkg.exit_code = None;
}
}
normalize_reachable_package_health(&mut packages).await;
let (current_data, _) = state.get_snapshot().await;
let tor_addr = docker_packages::read_tor_address("archipelago").await;
@@ -992,7 +1057,11 @@ async fn scan_and_update_packages(
// observability fields (health, exit_code, lan_address
// via installed) from the fresh scan so the UI still
// sees live readings.
let merged_entry = merge_preserving_transitional(existing_entry, pkg);
let merged_entry = merge_preserving_transitional(
existing_entry,
pkg,
user_stopped.contains(id),
);
if existing.cloned() != Some(merged_entry.clone()) {
merged.insert(id.clone(), merged_entry);
changed = true;
@@ -1029,6 +1098,19 @@ async fn scan_and_update_packages(
// owner (spawn_task) is responsible for clearing state, not us.
if let Some(entry) = merged.get(&id) {
if is_transitional(&entry.state) {
if let Some(replacement) = absent_transitional_replacement(&entry.state) {
let mut updated = entry.clone();
updated.state = replacement;
updated.health = None;
updated.exit_code = None;
updated.install_progress = None;
updated.uninstall_stage = None;
merged.insert(id.clone(), updated);
transitional_since.remove(&id);
absence_tracker.remove(&id);
changed = true;
continue;
}
let entered = *transitional_since.entry(id.clone()).or_insert(now);
let timeout = transitional_stuck_timeout(&entry.state);
if now.duration_since(entered) > timeout {
@@ -1088,6 +1170,99 @@ async fn scan_and_update_packages(
Ok(())
}
async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, pkg) in packages.iter_mut() {
if pkg.state != crate::data_model::PackageState::Running {
continue;
}
if !matches!(pkg.health.as_deref(), Some("starting" | "unhealthy" | "1")) {
continue;
}
let Some(port) = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref())
.and_then(port_from_url)
.or_else(|| fallback_package_port(id))
else {
continue;
};
if frontend_port_http_ready(port).await {
debug!(app_id = %id, port, "normalizing reachable package health to healthy");
pkg.health = Some("healthy".to_string());
ensure_main_lan_address(pkg, port);
}
}
}
async fn frontend_port_http_ready(port: u16) -> bool {
let Ok(Ok(mut stream)) = tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
else {
return false;
};
let request = b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
if stream.write_all(request).await.is_err() {
return false;
}
let mut buf = [0u8; 64];
let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await
else {
return false;
};
if n == 0 {
return false;
}
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.1 2")
|| head.starts_with("HTTP/1.1 3")
|| head.starts_with("HTTP/1.0 2")
|| head.starts_with("HTTP/1.0 3")
}
fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port: u16) {
let Some(installed) = pkg.installed.as_mut() else {
return;
};
let main = installed
.interface_addresses
.entry("main".to_string())
.or_insert_with(|| crate::data_model::InterfaceAddress {
tor_address: String::new(),
lan_address: None,
});
if main.lan_address.is_none() {
main.lan_address = Some(format!("http://localhost:{port}"));
}
}
fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),
"nostr-rs-relay" => Some(18081),
_ => None,
}
}
fn port_from_url(url: &str) -> Option<u16> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
let port = host_port.rsplit_once(':')?.1;
port.parse::<u16>().ok()
}
/// Register Archipelago DWN protocols on startup.
async fn register_dwn_protocols(data_dir: &std::path::Path) -> Result<()> {
use crate::network::dwn_store::{DwnStore, ProtocolDefinition};
@@ -1211,10 +1386,19 @@ mod merge_tests {
// not clobber the transitional state owned by the RPC spawn task.
let existing = make_entry(PackageState::Stopping, Some("healthy"));
let fresh = make_entry(PackageState::Running, Some("starting"));
let merged = merge_preserving_transitional(&existing, &fresh);
let merged = merge_preserving_transitional(&existing, &fresh, true);
assert_eq!(merged.state, PackageState::Stopping);
}
#[test]
fn non_user_stopping_recovers_when_container_is_running() {
let existing = make_entry(PackageState::Stopping, Some("unknown"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
#[test]
fn merges_fresh_observability_fields() {
// Non-state observability fields (health, exit_code, installed)
@@ -1224,7 +1408,7 @@ mod merge_tests {
existing.exit_code = None;
let mut fresh = make_entry(PackageState::Running, Some("unhealthy"));
fresh.exit_code = Some(0);
let merged = merge_preserving_transitional(&existing, &fresh);
let merged = merge_preserving_transitional(&existing, &fresh, true);
assert_eq!(merged.state, PackageState::Stopping);
assert_eq!(merged.health.as_deref(), Some("unhealthy"));
assert_eq!(merged.exit_code, Some(0));
@@ -1234,7 +1418,7 @@ mod merge_tests {
fn stale_removing_recovers_when_container_is_running() {
let existing = make_entry(PackageState::Removing, Some("unknown"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh);
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
@@ -1272,4 +1456,20 @@ mod merge_tests {
TRANSITIONAL_STUCK_TIMEOUT
);
}
#[test]
fn absent_stopping_transitions_to_stopped() {
assert_eq!(
absent_transitional_replacement(&PackageState::Stopping),
Some(PackageState::Stopped)
);
}
#[test]
fn absent_installing_still_waits_for_owner() {
assert_eq!(
absent_transitional_replacement(&PackageState::Installing),
None
);
}
}
+49 -1
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info};
use tracing::{debug, info, warn};
/// Live download progress counters. Updated by download_component_resumable
/// as bytes arrive and read by the update.status RPC so the UI can show
@@ -502,6 +502,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
.context("Reading update state")?;
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
let mut changed = false;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
@@ -517,11 +519,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
changed = true;
}
// `update_in_progress` means a manifest OTA is downloaded and staged,
// ready for apply. Older git/self-build update paths could leave this
// flag stuck true without a staging directory, which traps the UI in an
// unrecoverable state. Heal that on every state load.
if state.update_in_progress && !has_staged_update(data_dir).await {
warn!(
staging = %data_dir.join("update-staging").display(),
"Clearing stale update_in_progress without staged OTA files"
);
state.update_in_progress = false;
changed = true;
}
if changed {
save_state(data_dir, &state).await?;
}
Ok(state)
}
async fn has_staged_update(data_dir: &Path) -> bool {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
let path = data_dir.join(UPDATE_STATE_FILE);
let data = serde_json::to_string_pretty(state)?;
@@ -1764,6 +1791,11 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@@ -1800,6 +1832,22 @@ mod tests {
assert!(loaded.available_update.is_none());
}
#[tokio::test]
async fn test_load_state_clears_stale_in_progress_without_staging() {
let dir = tempfile::tempdir().unwrap();
let state = UpdateState {
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert!(!loaded.update_in_progress);
let persisted = load_state(dir.path()).await.unwrap();
assert!(!persisted.update_in_progress);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
+3 -3
View File
@@ -8,9 +8,9 @@ pub mod runtime;
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
pub use health_monitor::HealthMonitor;
pub use manifest::{
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
Volume,
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
SecretsProvider, SecurityPolicy, Volume,
};
pub use podman_client::{
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
+631 -9
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -42,6 +42,9 @@ pub struct AppDefinition {
#[serde(default)]
pub volumes: Vec<Volume>,
#[serde(default)]
pub files: Vec<GeneratedFile>,
#[serde(default)]
pub environment: Vec<String>,
@@ -51,6 +54,9 @@ pub struct AppDefinition {
#[serde(default)]
pub devices: Vec<String>,
#[serde(default)]
pub interfaces: HashMap<String, AppInterface>,
#[serde(flatten)]
pub extensions: HashMap<String, serde_yaml::Value>,
}
@@ -216,6 +222,8 @@ pub struct SecurityPolicy {
pub capabilities: Vec<String>,
#[serde(default = "default_true")]
pub readonly_root: bool,
#[serde(default = "default_true")]
pub no_new_privileges: bool,
#[serde(default = "default_network_policy")]
pub network_policy: String,
#[serde(default)]
@@ -263,6 +271,14 @@ pub struct Volume {
pub tmpfs_options: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GeneratedFile {
pub path: String,
pub content: String,
#[serde(default)]
pub overwrite: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCheck {
#[serde(rename = "type")]
@@ -277,6 +293,33 @@ pub struct HealthCheck {
pub retries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppInterface {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "type", default = "default_interface_type")]
pub interface_type: String,
pub port: u16,
#[serde(default = "default_http_protocol")]
pub protocol: String,
#[serde(default = "default_root_path")]
pub path: String,
}
fn default_interface_type() -> String {
"ui".to_string()
}
fn default_http_protocol() -> String {
"http".to_string()
}
fn default_root_path() -> String {
"/".to_string()
}
fn default_interval() -> String {
"30s".to_string()
}
@@ -302,8 +345,16 @@ impl AppManifest {
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.app.id.is_empty() {
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
if !is_valid_app_id(&self.app.id) {
return Err(ManifestError::Invalid(
"app.id must be lowercase ASCII letters, digits, or single hyphens".to_string(),
));
}
if self.app.name.trim().is_empty() {
return Err(ManifestError::Invalid(
"app.name cannot be empty".to_string(),
));
}
// Exactly one of container.image or container.build must be set. We can't
@@ -355,6 +406,11 @@ impl AppManifest {
"container.network cannot be empty (omit the field to use default)".to_string(),
));
}
if is_dangerous_network_mode(n) {
return Err(ManifestError::Invalid(format!(
"container.network '{n}' is not allowed in app manifests"
)));
}
}
// custom_args: no empty strings (would inject literal "" into
@@ -447,6 +503,12 @@ impl AppManifest {
}
}
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
// Volume tmpfs_options: only meaningful for type: tmpfs.
for (i, v) in self.app.volumes.iter().enumerate() {
if v.volume_type == "tmpfs" {
@@ -466,6 +528,11 @@ impl AppManifest {
v.volume_type
)));
} else {
if v.volume_type != "bind" && v.volume_type != "volume" {
return Err(ManifestError::Invalid(format!(
"volumes[{i}].type must be bind, volume, or tmpfs"
)));
}
if v.source.is_empty() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] ({}) must set source",
@@ -478,6 +545,45 @@ impl AppManifest {
v.volume_type
)));
}
if v.volume_type == "bind" {
validate_bind_source(i, &v.source)?;
} else if !is_valid_named_volume(&v.source) {
return Err(ManifestError::Invalid(format!(
"volumes[{i}].source must be a safe named volume"
)));
}
validate_container_path(i, &v.target)?;
validate_volume_options(i, &v.options)?;
}
}
for (i, f) in self.app.files.iter().enumerate() {
if f.path.is_empty() {
return Err(ManifestError::Invalid(format!(
"files[{i}].path cannot be empty"
)));
}
if !std::path::Path::new(&f.path).is_absolute() {
return Err(ManifestError::Invalid(format!(
"files[{i}].path must be absolute"
)));
}
if f.content.is_empty() {
return Err(ManifestError::Invalid(format!(
"files[{i}].content cannot be empty"
)));
}
let file_path = std::path::Path::new(&f.path);
let under_bind_mount = self
.app
.volumes
.iter()
.filter(|v| v.volume_type != "tmpfs" && !v.source.is_empty())
.any(|v| file_path.starts_with(std::path::Path::new(&v.source)));
if !under_bind_mount {
return Err(ManifestError::Invalid(format!(
"files[{i}].path must live under a bind-mounted volume source"
)));
}
}
@@ -485,6 +591,255 @@ impl AppManifest {
}
}
fn is_valid_app_id(id: &str) -> bool {
if id.is_empty() || id.starts_with('-') || id.ends_with('-') || id.contains("--") {
return false;
}
id.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_dangerous_network_mode(mode: &str) -> bool {
mode.starts_with("container:") || mode.starts_with("ns:")
}
fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
let allowed_network_policies = ["isolated", "bridge", "host"];
if !policy.network_policy.is_empty()
&& !allowed_network_policies.contains(&policy.network_policy.as_str())
{
return Err(ManifestError::Invalid(format!(
"security.network_policy must be one of {}",
allowed_network_policies.join(", ")
)));
}
let allowed_caps = [
"CHOWN",
"DAC_OVERRIDE",
"FOWNER",
"NET_ADMIN",
"NET_BIND_SERVICE",
"NET_RAW",
"SETGID",
"SETUID",
"SYS_ADMIN",
];
let mut seen = HashSet::new();
for cap in &policy.capabilities {
if !allowed_caps.contains(&cap.as_str()) {
return Err(ManifestError::Invalid(format!(
"security.capabilities contains unsupported capability '{cap}'"
)));
}
if !seen.insert(cap.as_str()) {
return Err(ManifestError::Invalid(format!(
"security.capabilities contains duplicate capability '{cap}'"
)));
}
}
Ok(())
}
fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
let mut seen_host = HashSet::new();
for (i, port) in ports.iter().enumerate() {
if port.host == 0 || port.container == 0 {
return Err(ManifestError::Invalid(format!(
"ports[{i}].host and ports[{i}].container must be non-zero"
)));
}
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
if protocol != "tcp" && protocol != "udp" {
return Err(ManifestError::Invalid(format!(
"ports[{i}].protocol must be tcp or udp"
)));
}
if !seen_host.insert((port.host, protocol.to_string())) {
return Err(ManifestError::Invalid(format!(
"ports contains duplicate host binding {}/{}",
port.host, protocol
)));
}
}
Ok(())
}
fn validate_interfaces(interfaces: &HashMap<String, AppInterface>) -> Result<(), ManifestError> {
let allowed_types = ["ui", "api", "metrics"];
let allowed_protocols = ["http", "https"];
for (key, interface) in interfaces {
if !is_valid_interface_key(key) {
return Err(ManifestError::Invalid(format!(
"interfaces key '{key}' must be lowercase ASCII letters, digits, hyphens, or underscores"
)));
}
if interface.port == 0 {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.port must be non-zero"
)));
}
if !allowed_types.contains(&interface.interface_type.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.type must be one of {}",
allowed_types.join(", ")
)));
}
if !allowed_protocols.contains(&interface.protocol.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.protocol must be one of {}",
allowed_protocols.join(", ")
)));
}
if !interface.path.starts_with('/') || interface.path.chars().any(char::is_control) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.path must start with '/' and contain no control characters"
)));
}
if interface
.name
.as_ref()
.is_some_and(|name| name.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.name cannot be empty when set"
)));
}
if interface
.description
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.description cannot be empty when set"
)));
}
}
Ok(())
}
fn is_valid_interface_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
let mut seen = HashSet::new();
for (i, entry) in env.iter().enumerate() {
let Some((key, _)) = entry.split_once('=') else {
return Err(ManifestError::Invalid(format!(
"environment[{i}] must be KEY=VALUE"
)));
};
if !is_valid_env_key(key) {
return Err(ManifestError::Invalid(format!(
"environment[{i}] has invalid key '{key}'"
)));
}
if !seen.insert(key) {
return Err(ManifestError::Invalid(format!(
"environment contains duplicate key '{key}'"
)));
}
}
Ok(())
}
fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
let mut seen = HashSet::new();
for (i, device) in devices.iter().enumerate() {
if !device.starts_with("/dev/") || device.contains("..") {
return Err(ManifestError::Invalid(format!(
"devices[{i}] must be an absolute /dev path"
)));
}
if !seen.insert(device.as_str()) {
return Err(ManifestError::Invalid(format!(
"devices contains duplicate entry '{device}'"
)));
}
}
Ok(())
}
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
let path = std::path::Path::new(source);
if !path.is_absolute() {
if is_valid_named_volume(source) {
return Ok(());
}
return Err(ManifestError::Invalid(format!(
"volumes[{index}].source must be absolute for host bind mounts or a safe named volume"
)));
}
if source.contains("..") {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].source must not contain '..'"
)));
}
if source.starts_with("/var/lib/archipelago/") || is_reviewed_host_bind_exception(source) {
return Ok(());
}
Err(ManifestError::Invalid(format!(
"volumes[{index}].source must be under /var/lib/archipelago or a reviewed host-bind exception"
)))
}
fn is_reviewed_host_bind_exception(source: &str) -> bool {
source == "/run/user/1000/podman/podman.sock" || source == "/var/run/dbus"
}
fn is_valid_named_volume(source: &str) -> bool {
if source.is_empty() || source.contains('/') || source.contains("..") {
return false;
}
source
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
fn validate_container_path(index: usize, target: &str) -> Result<(), ManifestError> {
if !std::path::Path::new(target).is_absolute() || target.contains("..") {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].target must be an absolute container path without '..'"
)));
}
Ok(())
}
fn validate_volume_options(index: usize, options: &[String]) -> Result<(), ManifestError> {
let allowed = ["rw", "ro", "z", "Z", "shared", "rshared", "slave", "rslave"];
let mut seen = HashSet::new();
for option in options {
if !allowed.contains(&option.as_str()) {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].options contains unsupported option '{option}'"
)));
}
if !seen.insert(option.as_str()) {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].options contains duplicate option '{option}'"
)));
}
}
Ok(())
}
/// Host facts available to `derived_env` templates at apply time.
///
/// Mirrors the values `scripts/container-specs.sh:detect_environment()`
@@ -657,6 +1012,92 @@ app:
assert_eq!(manifest.app.version, "1.0.0");
}
#[test]
fn typed_interfaces_parse_with_defaults() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
"#;
let manifest = AppManifest::parse(yaml).unwrap();
let main = manifest.app.interfaces.get("main").unwrap();
assert_eq!(main.interface_type, "ui");
assert_eq!(main.port, 8080);
assert_eq!(main.protocol, "http");
assert_eq!(main.path, "/");
}
#[test]
fn invalid_interfaces_are_rejected() {
let cases = [
(
"bad key",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
Bad Key:
port: 8080
"#,
"interfaces key",
),
(
"bad protocol",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
protocol: ftp
"#,
"interfaces.main.protocol",
),
(
"bad path",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
path: dashboard
"#,
"interfaces.main.path",
),
];
for (name, yaml, expected) in cases {
let err = AppManifest::parse(yaml).unwrap_err();
let ManifestError::Invalid(msg) = err else {
panic!("{name}: expected invalid manifest, got {err:?}");
};
assert!(
msg.contains(expected),
"{name}: expected error containing {expected:?}, got {msg:?}"
);
}
}
#[test]
fn test_manifest_validation() {
let yaml = r#"
@@ -864,6 +1305,38 @@ app:
);
}
#[test]
fn generated_files_must_live_under_bind_mounts() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /var/lib/archipelago/test-app
target: /data
files:
- path: /var/lib/archipelago/test-app/config.yaml
content: |
key: value
"#;
let manifest = AppManifest::parse(yaml).unwrap();
assert_eq!(manifest.app.files.len(), 1);
let bad = yaml.replace(
"/var/lib/archipelago/test-app/config.yaml",
"/etc/test-app/config.yaml",
);
let err = AppManifest::parse(&bad).unwrap_err();
assert!(
format!("{err}").contains("bind-mounted volume source"),
"unexpected error: {err}"
);
}
#[test]
fn empty_custom_arg_is_rejected() {
let yaml = r#"
@@ -1089,6 +1562,157 @@ app:
}
}
#[test]
fn unsafe_manifest_values_are_rejected() {
let cases = [
(
"bad app id",
r#"
app:
id: Bad_App
name: Bad
version: 1.0.0
container:
image: test/image:latest
"#,
"app.id",
),
(
"unsupported capability",
r#"
app:
id: bad-cap
name: Bad
version: 1.0.0
container:
image: test/image:latest
security:
capabilities: [SYS_MODULE]
"#,
"unsupported capability",
),
(
"docker socket bind",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
"#,
"reviewed host-bind exception",
),
(
"path-like relative bind source",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: data/cache
target: /data
"#,
"absolute for host bind mounts",
),
(
"bad environment key",
r#"
app:
id: bad-env
name: Bad
version: 1.0.0
container:
image: test/image:latest
environment:
- 1BAD=value
"#,
"invalid key",
),
(
"duplicate host port",
r#"
app:
id: bad-port
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8080, container: 80, protocol: tcp }
- { host: 8080, container: 81, protocol: tcp }
"#,
"duplicate host binding",
),
(
"bad device",
r#"
app:
id: bad-device
name: Bad
version: 1.0.0
container:
image: test/image:latest
devices:
- /tmp/fake-device
"#,
"absolute /dev path",
),
(
"container network namespace",
r#"
app:
id: bad-network
name: Bad
version: 1.0.0
container:
image: test/image:latest
network: container:host
"#,
"not allowed",
),
];
for (name, yaml, expected) in cases {
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(expected),
"case {name} expected '{expected}', got: {msg}"
);
}
}
#[test]
fn reviewed_host_bind_exceptions_parse() {
let yaml = r#"
app:
id: reviewed-binds
name: Reviewed Binds
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /run/user/1000/podman/podman.sock
target: /var/run/docker.sock
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
"#;
AppManifest::parse(yaml).unwrap();
}
#[test]
fn parse_every_real_manifest() {
let app_manifests = list_repo_manifests();
@@ -1099,7 +1723,6 @@ app:
let mut failures: Vec<String> = Vec::new();
let mut modern_count = 0usize;
let mut legacy_count = 0usize;
for path in app_manifests {
let content = fs::read_to_string(&path).expect("read manifest");
let parsed_yaml: serde_yaml::Value = match serde_yaml::from_str(&content) {
@@ -1121,15 +1744,14 @@ app:
failures.push(format!("{}: {err}", path.display()));
}
} else {
legacy_count += 1;
failures.push(format!(
"{}: expected modern app-schema manifest",
path.display()
));
}
}
assert!(modern_count > 0, "no modern app-schema manifests found");
assert!(
legacy_count > 0,
"expected at least one legacy manifest shape"
);
assert!(
failures.is_empty(),
+128 -20
View File
@@ -8,7 +8,7 @@ use crate::manifest::AppManifest;
use anyhow::{Context, Result};
use hyper::{Body, Request, Uri};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::net::UnixStream;
@@ -56,9 +56,9 @@ pub enum ContainerState {
impl From<&str> for ContainerState {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"created" => ContainerState::Created,
"created" | "initialized" => ContainerState::Created,
"running" => ContainerState::Running,
"stopping" => ContainerState::Stopping,
"stopping" | "removing" => ContainerState::Stopping,
"stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited,
"paused" => ContainerState::Paused,
@@ -109,33 +109,22 @@ impl PodmanClient {
/// Map container name to its UI launch URL
pub fn lan_address_for(name: &str) -> Option<String> {
if let Some(url) = manifest_lan_address_for(name) {
return Some(url);
}
let url = match name {
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
"homeassistant" => "http://localhost:8123",
"archy-mempool-web" | "mempool" => "http://localhost:4080",
"btcpay-server" => "http://localhost:23000",
"grafana" => "http://localhost:3000",
"searxng" => "http://localhost:8888",
"ollama" => "http://localhost:11434",
"cryptpad" => "http://localhost:3003",
"penpot" => "http://localhost:9001",
"nextcloud" => "http://localhost:8085",
"vaultwarden" => "http://localhost:8082",
"gitea" => "http://localhost:3001",
"jellyfin" => "http://localhost:8096",
"photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283",
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:8081",
"portainer" => "http://localhost:9000",
"uptime-kuma" => "http://localhost:3002",
"fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
"nostr-rs-relay" => "http://localhost:18081",
"indeedhub" => "http://localhost:7778",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
_ => return None,
};
@@ -388,7 +377,7 @@ impl PodmanClient {
"cap_add": cap_add,
"cap_drop": cap_drop,
"read_only_filesystem": manifest.app.security.readonly_root,
"no_new_privileges": true,
"no_new_privileges": manifest.app.security.no_new_privileges,
"restart_policy": "unless-stopped",
"restart_tries": 5,
"netns": {
@@ -633,6 +622,7 @@ fn podman_network_settings(
Some("bridge") => ("bridge", None),
Some("none") => ("none", None),
Some("slirp4netns") => ("slirp4netns", None),
Some("pasta") => ("pasta", None),
Some("private") => ("private", None),
Some(custom) => ("bridge", Some(custom.to_string())),
None if network_policy == "host" => ("host", None),
@@ -660,6 +650,100 @@ fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
ports
}
fn manifest_lan_address_for(container_name: &str) -> Option<String> {
for apps_dir in manifest_apps_dirs() {
let Ok(entries) = std::fs::read_dir(apps_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path().join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(manifest) = AppManifest::parse(&contents) else {
continue;
};
if manifest_runtime_names(&manifest)
.iter()
.any(|name| name == container_name)
{
if let Some(url) = manifest_primary_interface_url(&manifest) {
return Some(url);
}
if manifest_has_http_health(&manifest) {
if let Some(port) = manifest
.app
.ports
.iter()
.find(|port| port.protocol.eq_ignore_ascii_case("tcp"))
.map(|port| port.host)
{
return Some(format!("http://localhost:{port}"));
}
}
}
}
}
None
}
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
let main = manifest.app.interfaces.get("main")?;
if main.interface_type != "ui" {
return None;
}
Some(format!(
"{}://localhost:{}{}",
main.protocol, main.port, main.path
))
}
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
manifest
.app
.health_check
.as_ref()
.is_some_and(|health| health.check_type.eq_ignore_ascii_case("http"))
}
fn manifest_runtime_names(manifest: &AppManifest) -> Vec<String> {
let mut names = vec![manifest_container_name(manifest)];
match manifest.app.id.as_str() {
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => names.push(manifest.app.id.clone()),
"fedimint" => names.push("fedimintd".to_string()),
"immich" => names.push("immich_server".to_string()),
_ => {}
}
names
}
fn manifest_container_name(manifest: &AppManifest) -> String {
if let Some(v) = manifest.app.extensions.get("container_name") {
if let Some(s) = v.as_str() {
if !s.is_empty() {
return s.to_string();
}
}
}
match manifest.app.id.as_str() {
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
id => id.to_string(),
}
}
fn manifest_apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
dirs.extend([
Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(),
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
]);
dirs
}
fn parse_memory_limit(limit: &str) -> Option<i64> {
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
@@ -755,6 +839,30 @@ mod tests {
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
}
#[test]
fn lan_address_uses_manifest_http_port_for_regular_apps() {
assert_eq!(
PodmanClient::lan_address_for("filebrowser").as_deref(),
Some("http://localhost:8083")
);
}
#[test]
fn lan_address_prefers_manifest_main_interface() {
assert_eq!(
PodmanClient::lan_address_for("fedimint").as_deref(),
Some("http://localhost:8175/")
);
}
#[test]
fn lan_address_does_not_expose_tcp_only_service_ports() {
assert_eq!(
PodmanClient::lan_address_for("bitcoin-knots").as_deref(),
Some("http://localhost:8334")
);
}
#[test]
fn parse_memory_limit_iec_binary_suffixes() {
// Kubernetes-style — this is what apps/*/manifest.yml uses.
+31 -6
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use tokio::process::Command as TokioCommand;
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const PODMAN_CLI_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
#[async_trait]
@@ -150,7 +151,25 @@ impl ContainerRuntime for PodmanRuntime {
if is_missing_container_error(&stderr) {
return Ok(());
}
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
let zero_timeout = self.podman_cli(&["rm", "-f", "--time", "0", name]).await?;
if zero_timeout.status.success() {
return Ok(());
}
let _ = self.podman_cli(&["container", "cleanup", name]).await;
let cleanup_rm = self.podman_cli(&["rm", "-f", name]).await?;
if cleanup_rm.status.success() {
return Ok(());
}
let cleanup_stderr = String::from_utf8_lossy(&cleanup_rm.stderr);
if is_missing_container_error(&cleanup_stderr) {
return Ok(());
}
Err(api_err.context(format!(
"podman rm fallback failed: {}; cleanup rm failed: {}",
stderr.trim(),
cleanup_stderr.trim()
)))
}
}
}
@@ -196,20 +215,26 @@ impl ContainerRuntime for PodmanRuntime {
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
// `podman image exists` returns 0 if present, 1 if absent. Any other
// exit code is an environment failure we should surface.
let output = self.podman_cli(&["image", "exists", image_ref]).await?;
// Avoid `podman image exists`: on production nodes with a stressed
// rootless store it can hang even when targeted at one image. A bounded
// inspect is the local-storage probe the trait contract describes.
let output = self
.podman_cli_timeout(
&["image", "inspect", image_ref],
PODMAN_CLI_IMAGE_CHECK_TIMEOUT,
)
.await?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!(
"podman image exists {image_ref} exited with {code}: {stderr}"
"podman image inspect {image_ref} exited with {code}: {stderr}"
))
}
None => Err(anyhow::anyhow!(
"podman image exists {image_ref} terminated by signal"
"podman image inspect {image_ref} terminated by signal"
)),
}
}
+336 -19
View File
@@ -18,6 +18,7 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
@@ -555,6 +556,87 @@
</button>
</div>
</div>
<div class="glass-card p-6 mb-8">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
<div>
<h2 class="text-xl font-semibold text-white mb-2">Transaction Relay Sharing</h2>
<p class="text-white/70 text-sm">Trusted peer access for broadcasting transactions through this node</p>
</div>
<div class="px-3 py-2 bg-white/5 rounded-lg text-sm">
<span class="text-white/60">Local node</span>
<span class="ml-2 font-medium text-yellow-300" id="relaySyncStatus">Checking...</span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
<div class="p-4 bg-white/5 rounded-lg">
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">HTTPS Endpoint</div>
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayHttpsEndpoint">Not configured</div>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">HTTP Endpoint</div>
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayHttpEndpoint">Not configured</div>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">Tor Endpoint</div>
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayTorEndpoint">Not configured</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
<span class="text-white/80 text-sm">Allow peer use</span>
<input id="relayEnabledToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
</label>
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
<span class="text-white/80 text-sm">Allow requests</span>
<input id="relayRequestsToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
</label>
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
<span class="text-white/80 text-sm">Serve over Tor</span>
<input id="relayTorToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<input id="relayHttpsInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="https://rpc.example.com/">
<input id="relayHttpInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="http://192.168.1.2/">
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3">
<input id="relayTorInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="http://exampleonion.onion/">
<button class="glass-button px-4 py-2 rounded-lg text-sm font-medium" onclick="createRelayTorService()">Create Tor</button>
</div>
<button class="gradient-button px-4 py-2 rounded-lg text-sm font-medium" onclick="saveRelaySettings()">Save Sharing Settings</button>
</div>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3">
<select id="relayPeerSelect" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white" onchange="saveRelaySettings()">
<option value="">No trusted nodes available</option>
</select>
<button id="relayRequestButton" class="glass-button px-4 py-2 rounded-lg text-sm font-medium" onclick="requestPeerRelay()">Request Access</button>
</div>
<textarea id="relayRequestMessage" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35 min-h-[5rem]" placeholder="Optional note for the peer"></textarea>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-3">
<span class="text-white/70 text-sm">Restricted RPC user</span>
<span class="text-white/90 text-sm font-mono" id="relayCredentialUser">txrelay</span>
</div>
<div class="text-xs mt-2 text-white/50" id="relayCredentialStatus">Credential status unavailable</div>
</div>
<div>
<div class="text-sm font-semibold text-white mb-2">Relay Requests</div>
<div class="space-y-2" id="relayRequestsList">
<div class="text-sm text-white/50 p-3 bg-white/5 rounded-lg">No relay requests</div>
</div>
</div>
<div class="text-sm text-white/60" id="relayStatusMessage"></div>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
@@ -608,6 +690,7 @@
// RPC Configuration - Use local Nginx proxy within container
const RPC_ENDPOINT = 'bitcoin-rpc/';
const STATUS_ENDPOINT = 'bitcoin-status';
const ARCHY_RPC_ENDPOINT = 'rpc/v1';
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
// Make RPC call to Bitcoin node via local proxy
@@ -654,6 +737,232 @@
return response.json();
}
function cookieValue(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=')
.slice(1)
.join('=') || '';
}
async function callArchyRPC(method, params = {}) {
const headers = { 'Content-Type': 'application/json' };
const csrf = cookieValue('csrf');
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
const response = await fetch(ARCHY_RPC_ENDPOINT, {
method: 'POST',
headers,
credentials: 'include',
cache: 'no-store',
body: JSON.stringify({ method, params })
});
const body = await response.json().catch(() => ({}));
if (!response.ok || body.error) {
throw new Error(body.error?.message || `Archipelago RPC ${response.status}`);
}
return body.result;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function setText(id, value, fallback = 'Not configured') {
const el = document.getElementById(id);
if (el) el.textContent = value || fallback;
}
function setTextIfPresent(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
return el;
}
function setWidthIfPresent(id, value) {
const el = document.getElementById(id);
if (el) el.style.width = value;
return el;
}
function renderRelayRequests(requests = []) {
const list = document.getElementById('relayRequestsList');
if (!list) return;
if (!requests.length) {
list.innerHTML = '<div class="text-sm text-white/50 p-3 bg-white/5 rounded-lg">No relay requests</div>';
return;
}
list.innerHTML = requests.map(req => {
const name = escapeHtml(req.peer_name || req.peer_onion || req.peer_pubkey);
const message = req.message ? `<div class="text-xs text-white/50 mt-1">${escapeHtml(req.message)}</div>` : '';
const endpoint = req.approved_endpoint ? `<div class="text-xs text-white/50 mt-1 font-mono break-all">${escapeHtml(req.approved_endpoint)}</div>` : '';
const statusClass = req.status === 'approved'
? 'text-green-300'
: req.status === 'rejected'
? 'text-red-300'
: 'text-yellow-300';
const actions = req.direction === 'incoming' && req.status === 'pending'
? `<div class="flex gap-2 mt-3">
<button class="glass-button px-3 py-2 rounded-lg text-xs font-medium" onclick="approveRelayRequest('${escapeHtml(req.id)}')">Approve</button>
<button class="glass-button px-3 py-2 rounded-lg text-xs font-medium" onclick="rejectRelayRequest('${escapeHtml(req.id)}')">Reject</button>
</div>`
: '';
return `<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-white/80">${name}</div>
<div class="text-xs uppercase ${statusClass}">${escapeHtml(req.direction)} · ${escapeHtml(req.status)}</div>
</div>
${message}
${endpoint}
${actions}
</div>`;
}).join('');
}
function renderRelayPeers(peers = [], selectedPeer = '', localSynced = true) {
const select = document.getElementById('relayPeerSelect');
const button = document.getElementById('relayRequestButton');
if (!select) return;
if (!localSynced) {
select.innerHTML = '<option value="">Local Bitcoin node must finish syncing first</option>';
select.disabled = true;
if (button) button.disabled = true;
return;
}
if (!peers.length) {
select.innerHTML = '<option value="">No trusted nodes available</option>';
select.disabled = true;
if (button) button.disabled = true;
return;
}
select.disabled = false;
if (button) button.disabled = false;
select.innerHTML = '<option value="">Choose a trusted node</option>' + peers.map(peer => {
const label = escapeHtml(peer.name || peer.onion || peer.pubkey.slice(0, 16));
const approved = peer.relay_approved ? ' · approved' : '';
const selected = peer.pubkey === selectedPeer ? ' selected' : '';
return `<option value="${escapeHtml(peer.pubkey)}"${selected}>${label}${approved}</option>`;
}).join('');
}
async function loadRelayAccess() {
const statusEl = document.getElementById('relayStatusMessage');
try {
const relay = await callArchyRPC('bitcoin.relay-status');
const settings = relay.settings || {};
const local = relay.local_node || {};
setText('relayHttpsEndpoint', settings.https_endpoint);
setText('relayHttpEndpoint', settings.http_endpoint);
setText('relayTorEndpoint', settings.tor_endpoint);
const syncEl = document.getElementById('relaySyncStatus');
if (syncEl) {
syncEl.textContent = local.synced ? 'Synchronized' : 'Not synchronized';
syncEl.className = local.synced ? 'ml-2 font-medium text-green-300' : 'ml-2 font-medium text-yellow-300';
}
const enabled = document.getElementById('relayEnabledToggle');
const requests = document.getElementById('relayRequestsToggle');
const tor = document.getElementById('relayTorToggle');
if (enabled) enabled.checked = !!settings.enabled_for_peers;
if (requests) requests.checked = !!settings.allow_peer_requests;
if (tor) tor.checked = !!settings.allow_tor;
const httpsInput = document.getElementById('relayHttpsInput');
const httpInput = document.getElementById('relayHttpInput');
const torInput = document.getElementById('relayTorInput');
if (httpsInput && document.activeElement !== httpsInput) httpsInput.value = settings.https_endpoint || '';
if (httpInput && document.activeElement !== httpInput) httpInput.value = settings.http_endpoint || '';
if (torInput && document.activeElement !== torInput) torInput.value = settings.tor_endpoint || '';
renderRelayPeers(relay.trusted_nodes || [], settings.selected_peer_pubkey || '', !!local.synced);
renderRelayRequests(relay.requests || []);
setText('relayCredentialUser', relay.credentials?.username || 'txrelay', 'txrelay');
setText(
'relayCredentialStatus',
relay.credentials?.available ? `Credential file ready: ${relay.credentials.client_env_path}. ${relay.credentials.restart_hint || ''}` : 'Restricted relay credential will be generated when peer sharing is enabled',
'Credential status unavailable'
);
if (statusEl) statusEl.textContent = '';
} catch (error) {
console.warn('[Bitcoin UI] relay status failed', error);
if (statusEl) statusEl.textContent = `Relay controls unavailable: ${error.message}`;
}
}
async function saveRelaySettings() {
const statusEl = document.getElementById('relayStatusMessage');
const payload = {
enabled_for_peers: !!document.getElementById('relayEnabledToggle')?.checked,
allow_peer_requests: !!document.getElementById('relayRequestsToggle')?.checked,
allow_tor: !!document.getElementById('relayTorToggle')?.checked,
allow_https: !!document.getElementById('relayHttpsInput')?.value.trim(),
allow_http: !!document.getElementById('relayHttpInput')?.value.trim(),
selected_peer_pubkey: document.getElementById('relayPeerSelect')?.value || '',
https_endpoint: document.getElementById('relayHttpsInput')?.value.trim() || '',
http_endpoint: document.getElementById('relayHttpInput')?.value.trim() || '',
tor_endpoint: document.getElementById('relayTorInput')?.value.trim() || ''
};
try {
await callArchyRPC('bitcoin.relay-update-settings', payload);
if (statusEl) statusEl.textContent = 'Relay settings saved.';
await loadRelayAccess();
} catch (error) {
if (statusEl) statusEl.textContent = `Save failed: ${error.message}`;
}
}
async function requestPeerRelay() {
const statusEl = document.getElementById('relayStatusMessage');
const peer = document.getElementById('relayPeerSelect')?.value;
if (!peer) {
if (statusEl) statusEl.textContent = 'Choose a trusted node first.';
return;
}
try {
await callArchyRPC('bitcoin.relay-request-peer', {
peer_pubkey: peer,
message: document.getElementById('relayRequestMessage')?.value || ''
});
if (statusEl) statusEl.textContent = 'Relay access request sent.';
await loadRelayAccess();
} catch (error) {
if (statusEl) statusEl.textContent = `Request failed: ${error.message}`;
}
}
async function approveRelayRequest(id) {
await updateRelayRequest('bitcoin.relay-approve-request', id);
}
async function rejectRelayRequest(id) {
await updateRelayRequest('bitcoin.relay-reject-request', id);
}
async function updateRelayRequest(method, id) {
const statusEl = document.getElementById('relayStatusMessage');
try {
await callArchyRPC(method, { id });
if (statusEl) statusEl.textContent = 'Relay request updated.';
await loadRelayAccess();
} catch (error) {
if (statusEl) statusEl.textContent = `Update failed: ${error.message}`;
}
}
async function createRelayTorService() {
const statusEl = document.getElementById('relayStatusMessage');
try {
await callArchyRPC('bitcoin.relay-create-tor-service');
if (statusEl) statusEl.textContent = 'Tor service requested.';
await loadRelayAccess();
} catch (error) {
if (statusEl) statusEl.textContent = `Tor setup failed: ${error.message}`;
}
}
// Implementation branding — detected from getnetworkinfo.subversion.
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
let brandingApplied = false;
@@ -720,11 +1029,11 @@
syncStatusText.textContent = status.error || 'Bitcoin node is reconnecting... showing last known values';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
} else if (consecutiveRpcFailures < 6) {
syncStatusText.textContent = status.error || 'Connecting to Bitcoin node...';
syncStatusText.textContent = status.error || 'Bitcoin node is starting or busy syncing...';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
} else {
syncStatusText.textContent = status.error || 'Bitcoin node is not responding yet';
syncStatusText.className = 'text-red-400 text-sm font-medium';
syncStatusText.textContent = status.error || 'Bitcoin node is still syncing; retrying automatically...';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
}
}
if (syncIcon) {
@@ -818,7 +1127,9 @@
const rpcEl = document.getElementById('settingsRpc');
if (rpcEl) {
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
rpcEl.textContent = status.stale
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
const displayStale = status.stale === true && statusAgeMs > 30000;
rpcEl.textContent = displayStale
? `Reconnecting on port ${port}`
: `Reachable on port ${port}`;
}
@@ -831,6 +1142,10 @@
const isSynced = headers > 0 && blocks >= headers - 1 && !initialBlockDownload;
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
const previousBlockCount = lastBlockCount;
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;
// Calculate actual sync percentage based on blocks/headers
const actualSyncValue = headers > 0 ? (blocks / headers) * 100 : 0;
@@ -840,21 +1155,21 @@
// Animate block count if it changed
const currentHeightElem = document.getElementById('currentHeight');
if (blocks !== lastBlockCount && lastBlockCount > 0) {
if (currentHeightElem && blocks !== lastBlockCount && lastBlockCount > 0) {
currentHeightElem.classList.add('number-update');
setTimeout(() => currentHeightElem.classList.remove('number-update'), 500);
}
lastBlockCount = blocks;
currentHeightElem.textContent = blocks.toLocaleString();
document.getElementById('networkHeight').textContent = headers.toLocaleString();
document.getElementById('headers').textContent = headers.toLocaleString();
document.getElementById('verificationProgress').textContent = `${verificationPercentage}%`;
document.getElementById('syncPercentage').textContent = `${actualSyncPercentage}%`;
document.getElementById('currentBlock').textContent = appearsToBeReindexing
setTextIfPresent('currentHeight', blocks.toLocaleString());
setTextIfPresent('networkHeight', headers.toLocaleString());
setTextIfPresent('headers', headers.toLocaleString());
setTextIfPresent('verificationProgress', `${verificationPercentage}%`);
setTextIfPresent('syncPercentage', `${actualSyncPercentage}%`);
setTextIfPresent('currentBlock', appearsToBeReindexing
? 'Reindexing from disk'
: `Block ${blocks.toLocaleString()}`;
document.getElementById('syncProgressBar').style.width = `${progressWidth}%`;
: `Block ${blocks.toLocaleString()}`);
setWidthIfPresent('syncProgressBar', `${progressWidth}%`);
// Update sync status text and icon
const syncStatusText = document.getElementById('syncStatusText');
@@ -868,10 +1183,10 @@
syncIcon.classList.remove('text-green-500');
}
} else if (isSynced) {
syncStatusText.textContent = status.stale
syncStatusText.textContent = displayStale
? 'Bitcoin node is reconnecting... showing last known synchronized state'
: '✓ Fully synchronized with the network';
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
syncStatusText.className = displayStale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
// Stop spinning when synced
if (syncIcon) {
syncIcon.classList.remove('animate-spin-slow');
@@ -879,12 +1194,12 @@
}
} else {
const remaining = headers - blocks;
syncStatusText.textContent = status.stale
syncStatusText.textContent = displayStale
? 'Bitcoin node is reconnecting... showing last known sync state'
: initialBlockDownload
? `Initial block download... ${remaining.toLocaleString()} blocks remaining`
: `Syncing... ${remaining.toLocaleString()} blocks remaining`;
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
syncStatusText.className = displayStale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
// Keep spinning while syncing
if (syncIcon) {
syncIcon.classList.add('animate-spin-slow');
@@ -910,8 +1225,8 @@
if (syncStatusText) {
const hasRecentData = lastSuccessfulUpdateAt > 0 && Date.now() - lastSuccessfulUpdateAt < 120000;
syncStatusText.textContent = hasRecentData
? 'Bitcoin status bridge is reconnecting... keeping last known values'
: 'Connecting to Bitcoin status bridge...';
? 'Bitcoin status bridge is retrying... keeping last known values'
: 'Bitcoin status bridge is starting...';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
}
}
@@ -920,10 +1235,12 @@
// Initial update
console.log('[Bitcoin UI] Starting initial blockchain info update...');
updateBlockchainInfo();
loadRelayAccess();
// Update every 5 seconds
console.log('[Bitcoin UI] Setting up 5-second update interval');
setInterval(updateBlockchainInfo, 5000);
setInterval(loadRelayAccess, 15000);
function copyRPCInfo() {
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: archipelago123\nRPC Endpoint: ${RPC_ENDPOINT}`;
+16
View File
@@ -0,0 +1,16 @@
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
COPY assets/img/bg-network.jpg /usr/share/nginx/html/assets/img/bg-network.jpg
COPY assets/img/app-icons/fedimint.png /usr/share/nginx/html/assets/img/app-icons/fedimint.png
COPY assets/img/app-icons/fedimint.jpg /usr/share/nginx/html/assets/img/app-icons/fedimint.jpg
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 8175
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 KiB

+452
View File
@@ -0,0 +1,452 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="30">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Fedimint Guardian - Archipelago</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
color: white;
overflow-x: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", sans-serif;
background: #050505;
}
.bg-perspective-container {
position: fixed;
inset: 0;
z-index: -10;
perspective: 1000px;
perspective-origin: 50% 50%;
overflow: hidden;
}
.bg-layer {
position: absolute;
inset: 0;
background-image: url("/assets/img/bg-network.jpg");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transform-style: preserve-3d;
opacity: 1;
transform: translateZ(0) scale(1);
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
z-index: -5;
pointer-events: none;
}
.container {
width: min(1400px, 100%);
margin: 0 auto;
padding: 2rem;
padding-bottom: 4rem;
}
.glass-card,
.info-card {
position: relative;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border: none;
}
.glass-card {
border-radius: 16px;
padding: 24px;
overflow: hidden;
}
.info-card {
border-radius: 16px;
padding: 12px;
}
.glass-card::before,
.info-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 1;
}
.glass-card > *,
.info-card > * {
position: relative;
z-index: 2;
}
.header {
display: flex;
align-items: center;
gap: 24px;
}
.logo-gradient-border {
position: relative;
border-radius: 16px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
display: inline-block;
flex: 0 0 auto;
}
.logo-gradient-border::after {
content: "";
position: absolute;
inset: 3px;
border-radius: 13px;
background: #fff;
z-index: 0;
}
.logo-gradient-border img {
border-radius: 13px;
display: block;
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: cover;
}
.title {
flex: 1;
min-width: 0;
}
.title h1 {
font-size: clamp(28px, 4vw, 36px);
line-height: 1.12;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.title p,
.body-copy {
color: rgba(255, 255, 255, 0.7);
line-height: 1.55;
}
.status-strip {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 260px;
}
.status-dot-wrap {
position: relative;
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: rgb(251, 146, 60);
}
.status-dot-ping {
position: absolute;
inset: 0;
width: 12px;
height: 12px;
border-radius: 999px;
background: rgb(251, 146, 60);
opacity: 0.75;
animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.status-strip .label {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
line-height: 1.25;
}
.status-strip .value {
color: white;
font-size: 14px;
line-height: 1.25;
font-weight: 500;
}
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
gap: 24px;
margin-top: 24px;
}
.wait-card h2 {
font-size: clamp(24px, 4vw, 32px);
line-height: 1.18;
font-weight: 700;
margin-bottom: 12px;
}
.body-copy {
max-width: 70ch;
font-size: 15px;
}
.body-copy + .body-copy {
margin-top: 10px;
}
.readiness {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding: 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}
.readiness-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(251, 146, 60, 0.2);
color: rgb(251, 146, 60);
display: grid;
place-items: center;
flex: 0 0 auto;
}
.readiness-icon svg {
width: 22px;
height: 22px;
animation: spin 3s linear infinite;
}
.readiness .label {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
margin-bottom: 2px;
}
.readiness .value {
color: rgba(255, 255, 255, 0.92);
font-size: 15px;
font-weight: 500;
}
.detail-list {
display: grid;
gap: 12px;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}
.detail-row span:first-child {
color: rgba(255, 255, 255, 0.72);
font-size: 14px;
}
.detail-row span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 500;
text-align: right;
}
.progress-track {
position: relative;
height: 12px;
margin-top: 22px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.progress-track::before {
content: "";
position: absolute;
inset: 0;
width: 45%;
border-radius: inherit;
background: linear-gradient(90deg, rgb(251, 146, 60), rgb(250, 204, 21));
animation: indeterminate 1.8s ease-in-out infinite;
box-shadow:
0 0 10px rgba(251, 146, 60, 0.5),
0 0 20px rgba(251, 146, 60, 0.28);
}
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes indeterminate {
0% { transform: translateX(-110%); }
50% { transform: translateX(80%); }
100% { transform: translateX(230%); }
}
@media (max-width: 900px) {
.container {
padding: 18px;
}
.header {
align-items: flex-start;
flex-direction: column;
}
.status-strip {
max-width: none;
}
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.glass-card {
padding: 18px;
}
.logo-gradient-border img {
width: 56px;
height: 56px;
}
.detail-row {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.detail-row span:last-child {
text-align: left;
}
}
</style>
</head>
<body>
<div class="bg-perspective-container">
<div class="bg-layer"></div>
</div>
<div class="overlay"></div>
<main class="container">
<section class="glass-card">
<div class="header">
<div class="logo-gradient-border">
<img src="/assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
</div>
<div class="title">
<h1>Fedimint Guardian</h1>
<p>Guardian is installed and will open here automatically when Bitcoin Knots finishes initial block download.</p>
</div>
<div class="info-card status-strip">
<div class="status-dot-wrap">
<div class="status-dot"></div>
<div class="status-dot-ping"></div>
</div>
<div>
<p class="label">Status</p>
<p class="value">Waiting for Bitcoin sync</p>
</div>
</div>
</div>
</section>
<section class="main-grid">
<div class="glass-card wait-card">
<h2>Waiting for Bitcoin sync</h2>
<p class="body-copy">Fedimint needs a synced Bitcoin RPC before federation setup can start safely.</p>
<p class="body-copy">This page refreshes every 30 seconds and switches to the Guardian UI as soon as the backend is ready.</p>
<div class="readiness">
<div class="readiness-icon" aria-hidden="true">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</div>
<div>
<p class="label">Readiness check</p>
<p class="value">Checking bitcoind readiness...</p>
</div>
</div>
<div class="progress-track" aria-hidden="true"></div>
</div>
<aside class="glass-card">
<div class="detail-list">
<div class="detail-row">
<span>Bitcoin backend</span>
<span>Bitcoin Knots</span>
</div>
<div class="detail-row">
<span>Guardian launch</span>
<span>Automatic</span>
</div>
<div class="detail-row">
<span>Refresh interval</span>
<span>30 seconds</span>
</div>
<div class="detail-row">
<span>Required state</span>
<span>IBD complete</span>
</div>
</div>
</aside>
</section>
</main>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
server {
listen 8175;
server_name _;
proxy_intercept_errors on;
error_page 500 502 503 504 = @wait_page;
location /assets/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
}
location / {
proxy_pass http://127.0.0.1:8177;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location @wait_page {
root /usr/share/nginx/html;
add_header Cache-Control "no-store" always;
try_files /index.html =503;
}
}
+231
View File
@@ -0,0 +1,231 @@
# 1.8-alpha Improvements Tracker
Last updated: 2026-06-12 01:15 EDT
This tracks the user-facing improvement list that must land with the `1.8-alpha`
container migration release and the next ISO cut produced from that release. It
is intentionally separate from the container handoff docs, but should be treated
as release and ISO smoke-test scope.
Status legend:
- `todo`: not started.
- `in-progress`: active local work or validation.
- `blocked`: needs host access, hardware, credentials, a product decision, or an
external artifact.
- `done`: implemented and validated for this release.
- `defer?`: candidate to explicitly defer from `1.8-alpha` after product review.
Resume protocol:
1. Read this file after `docs/NEXT_TERMINAL_HANDOFF.md`.
2. Keep every user-requested improvement represented here until it is either
`done` or explicitly moved out of `1.8-alpha` by product decision.
3. When implementation starts, change status to `in-progress` and add the file,
test, host, or design decision being worked.
4. Mark `done` only after the change is implemented and validated locally or on
the release validation host, as appropriate.
5. Before cutting the next ISO, run this checklist as part of ISO smoke testing.
Active-session note, 2026-06-10 05:48 EDT: resumed from
`docs/NEXT_TERMINAL_HANDOFF.md`; no `.198` host actions have been run yet. The
immediate tracker-affecting local gate is rerunning the focused Rust
`container::image_versions::tests` validation for the Nextcloud false-update
row, then continuing lifecycle/control-plane truthfulness work.
Resume-save checkpoint, 2026-06-10 08:32 EDT: the current pass stayed on the
fixes backlog, not app migration. No `.198` host actions were run, no dev server
was intentionally left running, and no long-running validation command is
expected to still be active. Continue from the in-progress `Make tabs info load
quickly or show loading states` row or the next unresolved fixes-backlog row.
Active-session progress: `git diff --check` passed. Focused image-version Rust
validation is still inconclusive because the tool PTY stayed open with no
active compiler process visible, a bounded 300s retry using the normal
workspace target exited `124` before test output, and a fresh 600s retry in
`/tmp/archy-cargo-image-versions-2` also exited `124` after compiling into the
`archipelago` crate without reaching test output. The Nextcloud false-update
row remains `in-progress`. A local lifecycle fix is in progress so migrated
single-orchestrator app stops return immediately with a transitional state
instead of blocking the UI while Podman cleanup runs; `cargo fmt --check` and
focused backend compile check passed, and `git diff --check` is clean. Latest
credentials backlog follow-up added backend PhotoPrism credentials, centered
the mobile credential pre-launch modal in My Apps and the icon grid, and passed
focused frontend tests, type-check, backend compile check, `cargo fmt --check`,
and `git diff --check`. Web5 Connected Nodes Messages/Requests, Web5
Identities, and DWN message browsing now preserve visible content during
refresh/failure and show compact refresh labels instead of replacing populated
tabs with loading panels; focused tests and type-check passed. Server Network
overview, Network Interfaces, and Tor Services cards now keep visible values
during refresh or refresh failure and show compact refresh labels instead of
reverting to skeletons or false empty states; focused test and type-check
passed. The standalone Credentials view now keeps credential rows visible
during refresh/failure and shows `Refreshing credentials...`; focused test and
type-check passed. Lightning Channels now keeps existing channels visible
during refresh/failure and shows `Refreshing channels...`; focused test and
type-check passed. Peer Files now keeps existing peer catalog items visible
during Tor refresh/failure and shows `Refreshing peer files...`; focused test,
type-check, and `git diff --check` passed. Cloud peer cards now remain visible
during federation peer-list refresh/failure with `Refreshing peer nodes...`;
focused test, type-check, and `git diff --check` passed. The Web5 Verifiable
Credentials summary now keeps credential rows visible during refresh/failure
with `Refreshing credentials...`; focused test, type-check, and
`git diff --check` passed. Web5 Nostr Relays now keeps relay stats visible
during refresh/failure with `Refreshing relays...`; focused test, type-check,
and `git diff --check` passed. Web5 Domains now keeps registered-name counts
visible during refresh/failure with `Refreshing domains...`; focused test,
type-check, and `git diff --check` passed. Settings Backups now keeps existing
backup rows visible during refresh/failure with `Refreshing backups...`;
focused test, type-check, and `git diff --check` passed. Settings Transport
Preferences now keeps preference controls visible during refresh/failure with
`Refreshing transport preferences...`; focused test, type-check, and
`git diff --check` passed. Settings VPN status now keeps current connection
details visible during refresh/failure with `Refreshing VPN status...`;
focused test, type-check, and `git diff --check` passed. Web5 Federation now
shows `Refreshing federation...` during summary refresh and keeps existing node
counts/DID visible on refresh failure; focused test, type-check, and
`git diff --check` passed. Mesh map denied-location behavior now has component
coverage proving browser location denial reports that peer positions can still
appear without requiring local location; focused test, type-check, and
`git diff --check` passed. Companion/app-session mobile tab-app handling now
keeps apps that require a new tab inside the mobile session fallback instead of
auto-opening an external tab and closing; focused app-session, launcher, and
config tests passed with type-check and `git diff --check`.
Nostr Discoverable Nodes now keeps discovered rows visible during relay refresh
or relay failure and shows `Searching relays...`; focused test, type-check, and
`git diff --check` passed. App Store/App Details screenshot sections now render
only real screenshot metadata and no longer show fake placeholder tiles when no
assets exist; focused App Details content and marketplace handoff tests,
type-check, and `git diff --check` passed. Home now has an App Store
recommendations card driven by uninstalled core/recommended marketplace apps;
the recommendations respect installed aliases so apps drop out after install
and move into normal My Apps/Home behavior. Focused helper tests, type-check,
`git diff --check`, and the Playwright Home dashboard smoke passed. Easy Mode
goal configure steps now route to their owning app/screen, verify steps have an
explicit `Check & Continue` action, and configure/info/verify actions start
goal progress before completing the step; focused goal action/store tests,
type-check, and `git diff --check` passed. Setup path selection no longer shows
the disabled `Connect Existing (Coming Soon)` option; Fresh Start and Restore
from Seed are the only visible choices and route correctly. Focused onboarding
option/composable tests, type-check, and `git diff --check` passed. Header
responsiveness follow-up restored the primary My Apps/App Store/Websites
navigation to persistent desktop tabs at `md+` on My Apps, Discover, and
Marketplace; removed the desktop primary dropdowns; kept mobile dropdown
behavior; delayed App Store category collapse by lowering the search reserve and
header gap; and removed the My Apps desktop category dropdown. Focused
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
Browser smoke against the already-running local Vite/mock session is still next.
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
files exist, so failed legacy update attempts cannot leave the update screen
permanently stuck. Direct `update.git-apply` is gated behind
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
the local git/self-build path that requires `cargo`. `.116` was recovered from a
failed self-build attempt by applying its already-staged manifest OTA; it is now
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
symbol linker failure unrelated to the updater patch.
Done criteria for this tracker:
- Code/UI items: implemented, covered by targeted test or manual smoke check,
and no known regression against the container migration work.
- Runtime/container items: validated on the release host named in
`docs/NEXT_TERMINAL_HANDOFF.md`, then included in ISO smoke test scope.
- Product-decision items: documented decision plus implementation task if the
decision keeps it in `1.8-alpha`.
- External/hardware items: hardware/document/access obtained, or explicitly
deferred from the release by product decision.
## Release-Critical Runtime Gates
| Item | Status | Release question / blocker |
| --- | --- | --- |
| Check logs of every server for errors and fix | blocked | Needs explicit target server list. Current docs name `.198`; are there more production validation hosts? |
| Go through issues on gate | blocked | Need location of "gate" issue tracker/board and access details. |
| Sort out container tagging so databases, backend, etc are sorted properly | in-progress | Tie to manifest/catalog metadata and My Apps grouping. |
| Sort out supplementary container naming so it is better | in-progress | Needs naming convention for dependencies: app-prefixed service names vs role-first names. |
| Figure out how we offer updates to apps | todo | Product/runtime design needed: manual update, scheduled checks, or auto-update by app tier. |
| Figure out how we provide different versions for Bitcoin to download and keep updated automatically | todo | Requires release policy for Knots/Core versions and whether users may pin old versions. |
| Make sure all credentials are given for apps without registration | in-progress | File Browser now exposes credentials on App Details and in the pre-launch interstitial. Backend `package.credentials` returns the secured File Browser password from `/var/lib/archipelago/secrets/filebrowser/password` when present, with `admin/admin` fallback matching the install hook. PhotoPrism now exposes manifest-backed `admin` / `archipelago` credentials from both backend `package.credentials` and the frontend fallback. My Apps and mobile icon-grid credential pre-launch modals are vertically centered on mobile. Covered by `appCredentials.test.ts`, `AppIconGrid.test.ts`, local type-check, backend compile check, `cargo fmt --check`, and `git diff --check`. Grafana was not added because `GRAFANA_ADMIN_PASSWORD` is not resolved to a known repo default/secret. Remaining no-registration apps still need inventory. |
| Nextcloud always shows update, and how are apps actually updated? | in-progress | Nextcloud manifest/catalog metadata is aligned to the pinned `nextcloud:29` image, and update detection now ignores registry-host-only image changes while still reporting real same-repo tag drift. Catalog drift check passed. Backend focused test was added but local validation hit a Rust linker/incremental artifact failure, then bounded retries exited `124` before test output, including a 600s fresh-target retry on 2026-06-10. Broader app update UX/policy design still needed. |
| Make sure Tor is solid as having to rotate addresses to get it to work | todo | Needs `.198`/target-host Tor logs and reproducible failure case. |
| Fix fleet it does not seem to work | done | Fleet data now preserves existing nodes during refresh, exposes an explicit refreshing state, sorts online nodes first, avoids duplicate history fetches when selecting a node, accepts backend `entries` and legacy `history` response shapes for per-node charts, and uses readable loading/auto-refresh UI. Covered by `useFleetData.test.ts`, local type-check, targeted tests, and user visual review of the Fleet header/card treatment. |
| Check Beta Telemetry and how it works | done | Telemetry is opt-in via `analytics-config.json`; the background reporter runs every 15 minutes only when enabled, saves `telemetry-latest.json`, writes local Fleet reports/history under `telemetry-fleet/`, and optionally POSTs a `telemetry.ingest` JSON-RPC envelope to `TELEMETRY_COLLECTOR_URL`. The systemd unit now reads optional `/var/lib/archipelago/telemetry.env`, and deploys write that file when `TELEMETRY_COLLECTOR_URL` is exported in `scripts/deploy-config.sh`. Manual and periodic report schemas now both include metric percentages and container inventory, and the Fleet UI normalizes older reports with missing fields. Covered by local type-check, `useFleetData.test.ts`, `cargo check -p archipelago`, deploy-script syntax check, and `git diff --check`. Remaining ops step: choose the real collector URL, deploy it, restart the service, and confirm central Fleet ingest. |
| Get Netbird working | todo | Requires app/runtime validation and credentials/config expectations. |
| Sort out how we are going to manage lightning channel creation | todo | Product design needed for UX, safety limits, fees, and peer selection. |
| Make sure old health notifications do not return on refresh/new login when stale/out of date | done | Health toasts now require a current app-linked unhealthy package state and hide stale package health notifications after 30 minutes on reload/new login. Backend monitoring notifications now prune duplicate active alerts and old generic alerts before pushing new ones. Covered by `HealthNotifications.test.ts`, local type-check, targeted frontend tests, and backend notification unit test work. |
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
| Check mesh core is picking up public channel/other devices, not just Archipelago ones | blocked | Needs Meshtastic hardware/radio environment. |
| Make tabs info load quickly or show loading states | in-progress | Fleet now has initial loading/background-refresh states, and node history keeps showing while the next sample is fetched instead of blanking out. Web5 Connected Nodes Trusted/Observers tabs now show loading instead of empty states while peer data is pending and keep existing lists visible during refresh; Messages and Requests now also keep populated lists visible during refresh/failure. Web5 Shared Content now keeps My Content visible during refresh/failure with `Refreshing shared content...`, and Browse Peers keeps current same-peer results visible during refresh with `Refreshing peer content...` instead of replacing lists with full loading panels. Web5 Identities now keeps the identity list visible during refresh/failure with `Refreshing identities...`; Web5 DWN message browsing keeps stored messages visible during refresh/failure with `Refreshing messages...`. The Web5 Verifiable Credentials summary keeps credential rows visible during refresh/failure with `Refreshing credentials...`. Web5 Nostr Relays keeps relay stats visible during refresh/failure with `Refreshing relays...`. Web5 Domains keeps registered-name counts visible during refresh/failure with `Refreshing domains...`. Web5 Federation keeps summary node counts/DID visible during refresh/failure with `Refreshing federation...`. Server Network overview, Network Interfaces, and Tor Services cards now keep visible values during refresh/failure with `Refreshing network...`, `Refreshing interfaces...`, and `Refreshing Tor services...`. Credentials keeps credential rows visible during refresh/failure with `Refreshing credentials...`. Settings Backups keeps backup rows visible during refresh/failure with `Refreshing backups...`. Settings Transport Preferences keeps preference controls visible during refresh/failure with `Refreshing transport preferences...`. Settings VPN status keeps current connection details visible during refresh/failure with `Refreshing VPN status...`. Lightning Channels keeps existing channels visible during refresh/failure with `Refreshing channels...`. Peer Files keeps existing peer catalog items visible during Tor refresh/failure with `Refreshing peer files...`. Cloud keeps existing peer cards visible during federation peer-list refresh/failure with `Refreshing peer nodes...`. Covered by focused Web5/Server/Credentials/Backups/Transport/VPN/Lightning/Peer Files/Cloud tests and local type-check. Broader tab-info audit still needed for other slow panels before marking done. |
| Add states about why Bitcoin address is not ready | in-progress | Receive Bitcoin on-chain flows now reject blank LND address responses and translate common LND/Bitcoin readiness failures into user-facing reasons: wallet locked, wallet uninitialized, Bitcoin/LND still syncing, LND unreachable, or LND REST/newaddress transport issues. The receive modals now show a live “checking wallet readiness” message while the request is in flight. Backend `lnd.newaddress` now errors if LND returns an error or no address. Needs live wallet-state smoke test before marking done. |
| Add new Bitcoin wallets easily and securely | todo | Product/security design needed. |
| Add the new gate instead of gate | blocked | Need definition of "new gate" and target integration. |
| Local Nostr signer app should ask which account after logout/re-login | todo | Needs signer/session state validation. |
| See what apps can migrate to local Nostr signer sign-in | todo | Needs app-by-app auth inventory. |
| Make server name change change the host name | in-progress | Settings label changed to `Hostname`. `server.set-name` now persists the display name, derives a Linux-safe hostname slug, attempts `sudo -n hostnamectl set-hostname`, and returns non-fatal hostname warning fields if OS update fails. Covered by hostname slug unit test, local type-check, `cargo check -p archipelago`, and `git diff --check`. Impact audit: mDNS/SSH/Tailscale labels may change; already-created app configs using old `HOST_MDNS` (notably Fedimint derived env) are not automatically rewritten by hostnamectl, so this needs release-host smoke validation before marking done. |
| Sort out HTTPS certificate, what is best way? | todo | Needs product decision: self-signed local CA, ACME DNS, Tailscale certs, or reverse proxy model. |
## User Interface And App Experience
| Item | Status | Release question / blocker |
| --- | --- | --- |
| LND Channels then back/back gets stuck between LND detail and channels | done | App Details back now routes explicitly to the parent surface, and Lightning Channels back replaces history so browser back no longer bounces between LND detail and Channels. Validated by local type-check and targeted tests. |
| Add a Meshtastic icon | done | Added `meshcore.svg` asset and manifest-owned icon metadata. Catalog generation is idempotent and strict catalog drift is clean. |
| Improve default app icon fallback | done | Missing/broken app icons now fall back to the centered Archipelago `A` mark using the same black fill and gradient-border treatment as the custom UI icon asset, instead of the old generic placeholder. Applied to My Apps cards, mobile icons, Marketplace cards, and App Details. Validated by local type-check, targeted tests, Rust check, and `git diff --check`. |
| Use favicon for Portainer apps? | todo | Need decision: use upstream favicons dynamically or ship curated icons. |
| Settings for apps | blocked | Needs definition: per-app config screen, runtime env vars, credentials, or install options? |
| Update SearXNG app icon | blocked | Needs user-provided/approved icon asset. User said to move past this until they can make icons. |
| Once an app is installed remove recommended/core pills | done | Marketplace cards hide tier badges when installed. Validated by `MarketplaceAppCard.test.ts`, targeted Vitest, type-check, and `git diff --check`. |
| Get Bitcoin / LND UI fully done with all options and controls | todo | Large feature area; needs scope for `1.8-alpha` vs post-release. |
| Fix intro always showing on new browser sessions | done | Splash gating now checks the backend onboarding-complete state before showing the intro when this browser has no local intro flag. Already-onboarded nodes skip the splash and seed `neode_intro_seen`; fresh installs still show it. Covered by `introSplash.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Fix App Store tabs/categories/search overflow | done | Discover/App Store and Marketplace render one shared App Store section list. Follow-up after user review restored the primary My Apps/App Store/Websites navigation to persistent desktop tabs at `md+` on My Apps, Discover, and Marketplace; mobile keeps dropdown behavior. App Store category collapse now happens later by starting uncollapsed and using a smaller header gap/search reserve, and the My Apps category dropdown no longer appears on desktop. Covered by local type-check, focused Marketplace/App config tests, and scoped `git diff --check`; browser smoke remains the next resume step. |
| Add a test harness for all of the application | in-progress | Lifecycle harness exists; need expand UI/e2e coverage definition. |
| Fix app details screen links | done | App Details sidebar no longer renders dead `href="#"` links. It now renders only real manifest website/marketing, upstream/wrapper repo, and support URLs, and hides the Links card when no usable URLs exist. Covered by `AppSidebar.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Fix FIPS anchoring, update FIPS | todo | Needs expected FIPS UX/API behavior. |
| Fix generate receive address not working on nodes and identify wallet management | todo | Needs wallet API/backend validation. |
| Fix mesh page on larger screens so it scales nicely | done | Mesh keeps the tabbed tools layout on normal desktop/1920px widths and only splits Off-Grid Bitcoin, Dead Man, and Map into separate stacked containers on very large screens (`>=2560px` wide and `>=1200px` tall). The desktop tools column now fills its panel instead of using a wrapper scroll container. Validated by local type-check, targeted tests, and `git diff --check`. |
| Mesh map should handle denied location permission and still show other devices | in-progress | Mesh map now treats browser geolocation as optional in the UI: denied local location reports that peer locations can still appear, and the empty hint waits for mesh device positions instead of saying location sharing is required. Covered by `MeshMap.test.ts`. Needs browser smoke test with denied location plus a peer coordinate message before marking done. |
| Make tablet-size Meshtastic scrollable | done | Tablet/mobile Mesh tools panels now have bounded heights and internal scrolling so the selected Bitcoin/Dead Man/Map panel can scroll without blowing out the page. Validated by local type-check, targeted tests, and `git diff --check`. |
| Make mobile screens have gap below lowest container and tab bar | done | Dashboard route panels, including the separate Chat/Mesh branch, now use mobile tab-bar bottom clearance so the lowest content clears the bottom tab bar. |
| Add Trusted tab to Connected Nodes container and have Peers and Observers | done | Connected Nodes now labels trusted peers as Trusted and splits federation nodes with `trust_level: observer` into the Observers tab. Observer nodes are excluded from Trusted, shown with their own count/badge, and refresh from the same live federation list. Validated by local type-check and targeted tests. |
| Add more tree navigation to cloud files so they do not all go back to first screen | done | Cloud folder navigation now persists the current folder path in the route query so refresh/browser back keeps nested folders instead of resetting to the section root. The Cloud back button now walks up to the parent folder before returning to Cloud home. Covered by `cloudPath.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Fix visible UI refreshing on find nodes screens | done | Federation node auto-refresh no longer blanks/replaces the visible node lists after the initial load. Existing nodes stay visible during background refreshes, covered by `NodeList.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Remove dead UI components/ones that are coming soon | done | Removed the dead Web3/coming-soon Network card, disabled local-network placeholder button, and the non-interactive Spotlight AI Assistant coming-soon block. Verified active UI no longer contains explicit `Coming soon` copy outside historical release-note text. Covered by local type-check and `git diff --check`. |
| Hide Web3 container on network for now and move FIPS Mesh up | done | Network page now places the live FIPS Mesh card in the top overview grid where the dead Web3 card was, removes the duplicate lower FIPS card, and updates the Home Network description to remove Web3 language. Validated by local type-check, targeted tests, and `git diff --check`. |
| Make cool screens less hidden: Find Nodes, Fleet, Monitoring, etc. | done | Existing Web5 summary cards now expose Monitoring, Find Nodes/Federation, and Fleet directly. Federation card has separate `Find Nodes` and `Fleet` actions instead of hiding Find Nodes behind Fleet. Covered by `Web5Federation.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Fix dashboard container/card square rendering corruption | done | Generalized the App Store compositor workaround to dashboard scroll-panel glass cards/buttons/inputs and removed transform-based stagger movement so Chromium/Brave no longer paints random large black square/rectangle layers over containers. Kept the Web5 bottom-action placement change. Validated by local type-check, targeted tests, and `git diff --check`. |
| Move constrained card header actions to bottom buttons | done | Web5 summary actions and Network actions for Add Device, Scan WiFi, Restart Tor, and Add Service now stay in the card header only on very wide screens; otherwise they render at the card bottom as full-width or 50/50 buttons. Button icons were removed from those action buttons. Validated by local type-check, targeted tests, and `git diff --check`. |
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
| App Store screenshots | in-progress | Placeholder policy fixed: Marketplace App Details and installed App Details now render screenshot sections only when real screenshot metadata exists, and otherwise hide the fake placeholder tiles. Metadata can be string URLs or `{ src, alt }` objects. Covered by `AppContentSection.test.ts`, `useMarketplaceApp.test.ts`, local type-check, and `git diff --check`. Needs actual screenshot assets/metadata before marking done. |
| Fix App Detail page issues; container controls are not good | done | App Details container controls now disable while start/stop/restart/update/uninstall RPCs are running and show action-specific progress labels. Header actions collapse into the bottom 50/50 grid below `1280px` to avoid tablet/smaller desktop overlap. Credentials now show a loading state while package credentials are being fetched. Covered by `AppHeroSection.test.ts`, `AppSidebar.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Add setup instructions for apps that need them | done | App Details now renders a dedicated Setup Instructions card from `static-files.instructions` when present, so apps can show install/setup notes without a new schema. Covered by `AppSidebar.test.ts`, local type-check, and `git diff --check`. |
| Add press-and-hold option for apps on mobile app screen | done | Mobile My Apps icons now support long press/context menu to open the app detail/options screen while a normal tap still launches the app. Space key opens the same options path for keyboard users. Covered by `AppIconGrid.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Side-load: add port-not-available validation | done | Sideload modal now validates app ID collisions, malformed `host:container` mappings, reserved Archipelago/package host ports, and host ports already exposed by installed packages before queueing install. Backend install remains the final bind authority. Covered by `sideloadValidation.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
## External / Hardware Items
| Item | Status | Release question / blocker |
| --- | --- | --- |
| Buy a HaLow device and start integration | blocked | Requires hardware purchase and driver/device target. Not a code-only `1.8-alpha` item unless hardware is available now. |
+443
View File
@@ -0,0 +1,443 @@
# App Packaging Migration Plan
## Goal
Turn Archipelago into a serious app platform while preserving the fundamentals that drove the original architecture:
- Rootless Podman and security-first execution.
- Managed node-OS behavior: health, repair, backups, updates, secrets, and routing.
- Bitcoin/LND/Tor/Web5/mesh integration where the platform genuinely needs deep awareness.
- A developer-friendly app packaging model that avoids app-specific Rust installers as the normal path.
## Current Contract
The runtime contract is manifest-first. App packages live at `apps/<app-id>/manifest.yml` and are validated by the shared container manifest parser.
The current canonical manifest fields are:
- `app`: identity and app-level metadata.
- `container`: image or build source, pull policy, network, entrypoint, custom args, derived env, secret env, and data UID.
- `dependencies`: storage and app dependencies.
- `resources`: CPU, memory, disk.
- `security`: capabilities, read-only root, no-new-privileges, network policy, optional AppArmor profile.
- `ports`, `volumes`, `files`, `environment`, `health_check`, and `devices`.
- `metadata`: current catalog-facing presentation data such as category, tier, icon, repo/source, author, and features.
- extension keys may exist temporarily, but they are transitional and should not become a second contract.
The historical `archy-app.yml` name should be treated as superseded. The active local package filename is `manifest.yml`.
## Current Progress
As of the current `1.8-alpha` workstream:
- `apps/*/manifest.yml` is the source of truth for runtime app definitions.
- The Rust manifest parser validates app identity, image-vs-build source selection, safe environment/secrets, safe ports, safe bind/named/tmpfs volumes, generated files under declared bind mounts, devices, and security/network policy values.
- Manifest-owned generated files exist through `app.files` and have been used for app config material such as Meshtastic 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`.
- 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.
- Runtime package listings now derive LAN launch URLs from manifest-owned `interfaces.main` declarations or HTTP app ports before falling back to legacy compatibility aliases.
- Release drift checking is implemented by `scripts/check-app-catalog-drift.py --release --strict`.
- The canonical catalog and the UI public catalog are expected to remain byte-for-byte synced after generation.
- Runtime validation has already moved many simple and moderate apps into the manifest/orchestrator path, including Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, Gitea, Nextcloud, SearXNG, Nostr Relay, PhotoPrism, Jellyfin, Meshtastic, and several Bitcoin-adjacent apps.
The remaining migration work is mostly orchestration quality: post-reboot adoption, progress reporting, stale scanner-state handling, update policy, multi-container stack ownership, proxy route generation, and cleanup of obsolete legacy installers/fallbacks.
## Target Architecture
Use a StartOS-inspired package model with Umbrel-like app folders.
```text
apps/example-commerce/
manifest.yml
Dockerfile
icon.svg
screenshots/
instructions.md
hooks/
post-install.sh
pre-start.sh
repair.sh
health.sh
backup.sh
restore.sh
proxy/
routes.yml
```
Archipelago becomes the secure compiler/runtime for these packages. The manifest declares what it needs; Archipelago validates it, injects secrets, creates rootless Podman containers, generates nginx/Tor/public routes, registers health checks, displays credentials, and manages lifecycle.
## Core Principles
- App packages are declarative by default.
- Hooks are allowed only as controlled, reviewed escape hatches.
- Rootless Podman stays.
- Arbitrary privileged Compose execution is not allowed.
- Each app has one source of truth.
- Catalog, launch URLs, mobile behavior, credentials, backup paths, and public routes come from the app package or its generated catalog entry.
- Rust backend owns orchestration, not app-specific business logic.
- Core infrastructure can remain special-case where justified.
## What Stays
- Rootless Podman.
- Archipelago orchestrator.
- Health/reconcile/repair loops.
- Host nginx.
- Nginx Proxy Manager integration.
- Tor/public routing goals.
- Bitcoin/LND/mesh/Web5/FIPS/security direction.
- OTA update system.
- App-session/mobile shell.
- Managed secrets and credentials display.
## What Changes
- Complex app stacks stop living in Rust.
- `app-catalog/catalog.json` becomes generated.
- Frontend fallback marketplace data is removed or generated.
- App-session port maps and new-tab launch behavior become generated.
- Public proxy routes become app-declared.
- Install/start/restart/backup/restore become package-driven.
- App updates become app package changes where possible, not full backend code changes.
## Package Schema Direction
Example `manifest.yml`:
```yaml
app:
id: example-commerce
name: Example Commerce
version: 3.23.0
description: Composable commerce platform
container:
image: docker.io/myorg/example-commerce:1.0.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
- /app/start.sh
derived_env:
- key: PUBLIC_URL
template: https://{{HOST_MDNS}}:9010
secret_env:
- key: SALEOR_SECRET_KEY
secret_file: example-commerce-secret-key
dependencies:
- storage: 20Gi
resources:
cpu_limit: 4
memory_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
- host: 9010
container: 9000
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/example-commerce
target: /data
options: [rw]
environment:
- NODE_ENV=production
health_check:
type: http
endpoint: http://localhost:9000
path: /health
interval: 30s
timeout: 5s
retries: 3
```
Optional generated files, hooks, icons, and screenshots can sit beside the manifest, but the manifest stays the source of truth. Compose-style definitions are not executed directly.
## Security Model
Do not run arbitrary Compose directly. Archipelago validates:
- No privileged containers unless explicitly approved.
- No host filesystem mounts outside approved paths.
- No Docker socket mounts.
- No host network unless explicitly approved.
- No dangerous capabilities by default.
- No arbitrary device access without declaration.
- No rootful execution.
- Pinned images preferred.
- Resource limits required.
- Backup paths declared where the app stores durable data.
- Public routes explicit.
- Secrets referenced by name, not hardcoded.
When the runtime needs app-specific facts that do not belong in the manifest, prefer adding a reusable platform primitive rather than introducing another ad hoc installer path.
This preserves the reason for avoiding raw Umbrel-style Compose while still giving developers a sane package format.
## Lifecycle Model
Every app package should support:
- install
- configure
- start
- stop
- restart
- update
- repair
- health
- backup
- restore
- uninstall
- migrate
Archipelago owns the state machine.
Optional hooks:
- `post-install.sh` for migrations/admin creation.
- `pre-start.sh` for ownership repair.
- `repair.sh` for app-specific remediation.
- `health.sh` for custom health checks.
- `backup.sh` and `restore.sh` only when simple path backups are insufficient.
Hooks run with a controlled environment and restricted permissions.
## Hard Work
The hard work is not writing YAML. The hard work is safely translating app packages into reliable rootless runtime behavior:
- Build a robust package validator.
- Map a safe Compose subset to rootless Podman.
- Handle multi-container networks without hardcoded IPs.
- Handle rootless volume ownership correctly.
- Generate host nginx routes from app metadata.
- Handle public-domain apps without leaking private `192.168.x.x` or `100.x.x.x` URLs.
- Inject secrets without exposing values in logs or frontend bundles.
- Make backup/restore consistent across databases and files.
- Migrate existing hand-built containers to package-owned containers.
- Keep old alpha nodes working while introducing the new system.
- Avoid keeping two permanent systems that drift forever.
## Alpha Node Impact
Existing alpha nodes must not be broken.
Phase 1 behavior:
- Current Rust installers keep working.
- Current app manifests keep working.
- New app package loader exists beside the old system.
- No existing app is automatically migrated.
- Alpha nodes receive compatibility code only.
Phase 2 behavior:
- New installs of selected apps use package mode.
- Existing installs can be detected and adopted.
- App state is preserved.
- Migration is opt-in or happens only for low-risk apps.
Phase 3 behavior:
- Stable migrated apps switch to package mode by default.
- Existing containers are adopted if names/volumes match.
- Data directories are preserved.
- Old Rust installers remain as fallback for at least one release cycle.
Phase 4 behavior:
- Remove old installers only after live alpha validation.
- Keep migration repair code for already-deployed nodes.
## Migration Rules
For every migrated app:
- Preserve `/var/lib/archipelago/<app>` data.
- Preserve generated secrets.
- Preserve credentials shown to users.
- Preserve public ports where possible.
- Preserve container names where needed for adoption.
- Never delete volumes during migration.
- Stop/recreate containers only when necessary.
- Record migration version in app state.
- Provide rollback path to old installer for alpha builds.
## Notes For The Release
- Catalog entries should be generated from manifests so the UI and runtime agree on launch metadata.
- The developer docs should describe the manifest/runtime contract that exists today, not the older publish-model draft.
- If a new capability is needed, add one reusable manifest field or orchestrator primitive and document it here before wiring a one-off app branch.
## First Apps To Migrate
Start with low-risk apps:
- Filebrowser
- Vaultwarden
- Uptime Kuma
- Grafana
Then moderate apps:
- Gitea
- Nextcloud
- SearXNG
- Nginx Proxy Manager metadata integration
Then complex apps:
- Mempool
- BTCPay Server
- NetBird only if safe
Leave for later:
- Bitcoin
- LND
- Electrs/ElectrumX
- Tor
- System update
- Mesh/Web5/FIPS core services
## Complex Stack Reference Goal
Saleor has been removed from the supported release catalog until it has a real
manifest-owned package. A future complex stack should become the showcase
package and prove:
- Multi-container stack support.
- Generated secrets.
- Post-install migration/admin user hooks.
- Dashboard/API/storefront routes.
- Same-origin public GraphQL routing.
- Credentials display.
- Backup paths.
- Health checks.
- Public domain support.
- Alpha-node adoption.
Once a complex stack is clean, the app system is credible.
## Implementation Phases
### Phase 1: Package Contract
- Use `apps/<app-id>/manifest.yml` as the package contract.
- Keep the Rust parser/validator as the canonical schema implementation.
- Keep generated catalog output from manifest-owned metadata.
- Finish generated app-session launch metadata so launch behavior cannot drift from manifests.
- Add/keep tests for unsafe package rejection.
### Phase 2: Single-Container Runtime
- Continue hardening package install for one-container apps.
- Compile manifests to rootless Podman/Quadlet runtime behavior.
- Support ports, env, generated files, devices, volumes, resources, health checks, data UID repair, image pull/build availability checks, and launch metadata.
- Keep Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, SearXNG, Jellyfin, PhotoPrism, Meshtastic, and similar apps as regression proofs.
### Phase 3: Multi-Container Runtime
- Decide whether multi-container stacks use a safe `compose.yml` subset or a manifest-native `services` section.
- Support app-local networks.
- Support service dependencies and readiness gates.
- Support internal service names.
- Support generated env/secrets across services.
- Support controlled hooks only where declarative primitives are insufficient.
- Adopt existing multi-container apps without deleting data.
### Phase 4: Routing
- Add `proxy/routes.yml`.
- Generate host nginx routes.
- Generate Tor/public routes.
- Fix same-origin API routing class of bugs permanently.
- Integrate with Nginx Proxy Manager sync.
### Phase 5: Migration
- Add adoption logic for existing containers.
- Add migration metadata.
- Migrate simple apps.
- Migrate a serious multi-container app once the stack model is stable.
- Keep rollback.
- Prove reboot recovery with repeated clean post-reboot lifecycle passes.
- Preserve Nostr signer bridges, Bitcoin dependency wait states, and public launch ports during adoption.
### Phase 6: Cleanup
- Remove duplicated catalog/frontend data.
- Remove migrated Rust stack installers.
- Document package format.
- Add developer tooling: validate, test, package, install locally.
- Remove stale fallback metadata, app-specific lifecycle branches, and compatibility shims only after live validation.
## Developer Tooling
Add commands like:
```bash
archy app validate apps/example-commerce
archy app render apps/example-commerce
archy app install apps/example-commerce
archy app test apps/example-commerce
```
Developers should be able to package an app without understanding Archipelago internals.
## Open Source Story
Public explanation:
> Archipelago uses rootless Podman and a validated app package format. App authors define services declaratively, while the OS enforces security, secrets, routing, backups, health, and lifecycle repair. This gives us Umbrel-like app packaging with StartOS-like managed service discipline.
## Rework Estimate
- Package schema and validator: 1-2 weeks.
- Single-container package runtime: 1-2 weeks.
- Generated catalog/frontend metadata: 1 week.
- Multi-container support: 2-4 weeks.
- Routing/public proxy integration: 1-2 weeks.
- Hooks/secrets/backups: 2-3 weeks.
- First migrations: 2-4 weeks.
- Complex stack reference migration: 1-2 weeks.
- Cleanup/docs/tooling: 2-3 weeks.
Total estimate: 8-14 weeks of serious work for an excellent system.
Minimum viable version: 3-5 weeks.
## Biggest Risks
- Rootless Podman edge cases continue to bite.
- Compose compatibility scope creeps too wide.
- Hooks become an unsafe escape hatch.
- Migration accidentally disrupts alpha nodes.
- Generated metadata drifts from old manual data during transition.
- Old and new systems remain permanently duplicated.
## Risk Controls
- Support a strict Compose subset, not all Compose.
- Validate everything.
- Keep hooks minimal and logged.
- Migrate one app at a time.
- Add live alpha-node checks before each release.
- Generate catalog/app-session data early.
- Set a deadline for deleting migrated legacy installers.
## Immediate Next Steps
1. Expand generated app-session metadata beyond ports/titles/new-tab behavior to cover proxy paths and companion UI aliases where those can be declared safely in manifests.
2. Define the app update policy and wire it into manifest/catalog metadata.
3. Finish post-reboot adoption and stale scanner-state handling for migrated apps.
4. Convert remaining multi-container legacy stacks to a manifest-owned model without deleting data.
5. Add developer tooling around the current `manifest.yml` contract: validate, render, local install, lifecycle test.
6. Migrate a serious multi-container app as the proof package once the stack model is stable.
7. Leave Bitcoin/LND/core services as managed infrastructure until the package system is proven for normal apps.
+577 -1
View File
@@ -1,6 +1,582 @@
# Container Lifecycle Handoff
Last updated: 2026-05-11
Last updated: 2026-06-08
## 2026-06-08 `1.8-alpha` Release Gate Update
- Target release is now `1.8-alpha`, including a cut and smoke-tested ISO after validation is green.
- Current release readiness estimate is about `82%`.
- Host reboot validation is not clean yet. User reported that a reboot test left IndeeHub stopped afterward, with many containers killed by SIGKILL during reboot/shutdown, one crash, and a couple stopped.
- Treat post-reboot recovery as the active release blocker.
- IndeeHub is not considered recovered unless:
- the stack containers recover after boot;
- `http://192.168.1.198:7778/` is reachable;
- the HTML includes `/nostr-provider.js`;
- `http://192.168.1.198:7778/nostr-provider.js` is served and looks like the Nostr signer bridge.
- Local follow-up in progress:
- `core/archipelago/src/container/prod_orchestrator.rs` now hardens IndeeHub stack reconcile by starting existing backend containers through a user scope when possible, waiting for backend/API dependency readiness, restarting the frontend when it does not remain running/reachable, and checking host port `7778`;
- `tests/lifecycle/remote-lifecycle.sh` now validates the IndeeHub Nostr provider during launch probes;
- `core/container/src/manifest.rs` now has stricter package safety validation while preserving all current real manifests.
- Validation passed locally for this follow-up:
- `cargo fmt --manifest-path core/Cargo.toml --all`;
- `cargo test --manifest-path core/Cargo.toml -p archipelago-container` (`45 passed`);
- `cargo check --manifest-path core/Cargo.toml -p archipelago -p archipelago-container`;
- filtered `cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago indeedhub` compiled and ran one matching existing test;
- `bash -n tests/lifecycle/remote-lifecycle.sh`;
- `git diff --check`.
- Passing criterion after deploy:
- minimum: 3 consecutive clean post-fix reboots, broad non-destructive lifecycle green after each;
- preferred before release: 5 consecutive clean post-fix reboots, broad lifecycle green after each;
- SIGKILL during shutdown is not automatically disqualifying if all managed apps recover and pass health/launch after boot, but any stopped/crashed/unreachable managed app after boot fails that iteration.
- Final release gate after reboot validation: cut the `1.8-alpha` ISO and smoke-test boot/install/backend/UI/catalog/focused app lifecycle.
### 2026-06-08 Focused Blocker Validation After `06420c...`
- Deployed backend `4108ca146b482c028ae8d7c4bec314b71ef3412f15efd2e61846a2c345b36aba`, then backend `06420c0377fff650a2bf3211f13c1e0754bf8df81345b8485f4c9a30cb552439` to `.198`.
- Both deploys restarted only `archipelago.service`; `archipelago-doctor.timer` and `archipelago-reconcile.timer` stayed inactive. No reboot and no broad Podman store/image commands were run.
- Local fixes included:
- targeted Podman remove fallback for stuck `removing/stopping` records;
- rootless Podman socket liveness check by Unix connection, not path existence;
- IndeeHub readiness fallback to platform network aliases when `getent` inside the API image cannot prove DNS;
- Tailscale launch harness now requires login/auth UI content;
- stricter manifest validation while preserving all real manifests.
- Validation passed locally:
- `cargo fmt --manifest-path core/Cargo.toml --all`;
- `cargo check --manifest-path core/Cargo.toml -p archipelago -p archipelago-container`;
- `cargo test --manifest-path core/Cargo.toml -p archipelago-container` (`45 passed`);
- `bash -n tests/lifecycle/remote-lifecycle.sh`;
- `git diff --check`.
- `.198` is still not release-ready after `06420c...`:
- `indeedhub`: stuck `stopping`, launch `7778` returns `000`;
- `immich`: `starting`, launch `2283` returns `000`;
- `tailscale`: `running`, launch `8240` returns `000`; logs show `NeedsLogin`/`WantRunning=false`, and launch must present the Tailscale login/auth UI;
- `vaultwarden`: absent/not listed after start attempt, launch `8082` returns `000`;
- `portainer`: `running`, launch `9000` returns `000`; user confirmed Portainer environment wizard cannot connect to `unix:///var/run/docker.sock`;
- `btcpay-server`: not a current blocker; direct launch `23000` returned HTTP 200 and user confirmed the earlier report was wrong-server/slowness.
- Do not continue to reboot validation or ISO cutting until rootless Podman control-plane/socket health, stuck container-state cleanup, and app-screen launch contracts are fixed.
## 2026-06-08 `.198` Release Candidate State Check
- Deployed backend hash `7e82532137292e91111f63819d1be7fa69f994ce20d6b5e0194915f194f20412` to `.198` after the targeted image-probe mitigation.
- Previous live backend hash before deploy was `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de`.
- Deployment notes:
- local release build passed: `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release`;
- initial direct `cp` over `/usr/local/bin/archipelago` failed with `Text file busy`, after creating a timestamped backup;
- recovered by installing to `/usr/local/bin/archipelago.new`, atomically renaming it over `/usr/local/bin/archipelago`, and restarting only `archipelago.service`;
- no host reboot and no broad Podman store/image commands were run.
- Latest mitigation now live on `.198`:
- `core/container/src/runtime.rs` uses bounded targeted `podman image inspect` for `ContainerRuntime::image_exists()`;
- `core/archipelago/src/api/rpc/package/install.rs` uses bounded targeted `podman image inspect` for local fallback and post-pull verification;
- `core/archipelago/src/container/companion.rs` uses `podman image inspect` for companion image checks.
- Validation passed on live hash `7e82532137292e91111f63819d1be7fa69f994ce20d6b5e0194915f194f20412`:
- focused non-destructive lifecycle: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=photoprism,fedimint,indeedhub ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`;
- broad non-destructive lifecycle: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`;
- `python3 scripts/check-app-catalog-drift.py --release --strict` reports `metadata_drift=0`, `missing_catalog=0`, `missing_manifests=0`.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `7e82532137292e91111f63819d1be7fa69f994ce20d6b5e0194915f194f20412`.
- `/`: `66%` used, about `9.6G` free.
- `/var/lib/archipelago`: `8%` used, about `375G` free.
- Startup logs still showed one known `podman ps -a --format json timed out after 30s` scan timeout followed by scan backoff; lifecycle validation passed anyway. Treat Podman socket/store health as a residual release risk, but release image probes are now quarantined from the known fragile image-existence/list commands.
- Remaining release gate: host reboot validation, only if explicitly approved.
- Verified `.198` without running broad Podman store/image commands.
- Current local release binary and live `/usr/local/bin/archipelago` match hash `670a3e789540082437c7521cc5ad7a4c260f56ee8e0a9cf770160fa25b4e4644`.
- `archipelago.service` is active.
- `archipelago-doctor.timer` is inactive.
- `archipelago-reconcile.timer` is inactive.
- `/` is at `65%` used with about `9.9G` free.
- `/var/lib/archipelago` is at `10%` used with about `370G` free.
- Backend-restart validation was already recorded as passed in the release-candidate checkpoint. The remaining live validation gate is host reboot validation, only if explicitly approved.
- Continue avoiding `podman image list`, `podman system df`, broad `podman image exists`, `podman image prune`, and `podman volume prune` on `.198` while the store/socket health risk is unresolved.
## 2026-06-08 Local Release Gate Completion
- No `.198` host actions were performed in this pass: no reboot, no timer changes, no deploy, no Podman store-wide commands.
- Fixed scanner skip/backoff wakeups so skipped scans still advance the scan-completion watch counter for install/update waiters.
- Fixed local full-test blockers:
- crash-recovery unit tests now pass the `include_stack_members` flag and cover generic-vs-stack recovery behavior;
- runtime manifest-port lookup checks the workspace `apps/` directory via `CARGO_MANIFEST_DIR`, so new public manifests are visible from test/runtime working directories;
- journal disk usage parsing accepts compact `journalctl` output such as `463.9M`;
- boot-reconciler cadence tests bypass the global crash-recovery wait gate when using the existing test-only `without_companion_stage()` helper.
- Local validation passed:
- `cargo fmt --manifest-path core/Cargo.toml --all`.
- `cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago` (`688 passed`).
- `cargo test --manifest-path core/Cargo.toml -p archipelago-container` (`43 passed`).
- `cargo check --manifest-path core/Cargo.toml -p archipelago -p archipelago-container`.
- `cargo check --manifest-path core/Cargo.toml -p archipelago-performance -p archipelago-security`.
- `cargo test --manifest-path core/Cargo.toml -p archipelago-performance -p archipelago-security` (`12 security tests passed`; performance has no tests).
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release`.
- `python3 scripts/generate-app-catalog.py` (`updated 0 fields`).
- `python3 scripts/check-app-catalog-drift.py --release --strict`.
- `python3 -m py_compile scripts/generate-app-catalog.py scripts/check-app-catalog-drift.py scripts/app-catalog-image-smoke-test.py`.
- `git diff --check`.
- `cmp -s app-catalog/catalog.json neode-ui/public/catalog.json`.
- Remaining live gate is unchanged: host reboot validation on `.198`, only if explicitly approved.
## 2026-06-08 Frontend Release Gate Completion
- No `.198` host actions were performed in this pass: no reboot, no timer changes, no deploy, no Podman store-wide commands.
- Fixed mobile app-launch behavior in `neode-ui/src/stores/appLauncher.ts`:
- desktop still opens X-Frame-Options/new-tab apps directly in a new tab;
- mobile now routes those same apps through `app-session` so app icons keep users inside Archipelago;
- router return-path handling is defensive when `currentRoute` is unavailable.
- Updated frontend tests for current launch behavior and fixed async/Pina fixture setup.
- Local validation passed:
- `npm run type-check`.
- `npm test` (`548 passed`).
- `npm run build`.
- `python3 scripts/generate-app-catalog.py` (`updated 0 fields`).
- `python3 scripts/check-app-catalog-drift.py --release --strict`.
- `python3 -m py_compile scripts/generate-app-catalog.py scripts/check-app-catalog-drift.py scripts/app-catalog-image-smoke-test.py`.
- `cmp -s app-catalog/catalog.json neode-ui/public/catalog.json`.
- `git diff --check`.
- Local caveat: `npm ci` failed before checks because existing `neode-ui/node_modules/@alloc` entries are `root:root`; do not mutate ownership or remove the tree without explicit approval.
## 2026-06-08 Local Podman Store-Risk Cleanup
- Reviewed release-relevant Podman store/image call sites without running broad Podman store/image commands on `.198`.
- Bounded stack installer image pulls in `core/archipelago/src/api/rpc/package/stacks.rs` with `kill_on_drop` and a 600s timeout.
- Bounded manual package update image pulls in `core/archipelago/src/api/rpc/package/update.rs` with `kill_on_drop` and a 600s timeout while preserving stderr progress parsing.
- Validation passed locally:
- `python3 scripts/check-app-catalog-drift.py --release --strict`.
- `cargo fmt` from `core/`.
- `cargo check --manifest-path core/Cargo.toml -p archipelago -p archipelago-container`.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release`.
- Local release binary hash after this cleanup is `a52a87474c9a788e058ee1da1edd6091ab305594a53e7a153889f77041598ff4`.
- This local build has not been deployed to `.198`; live `.198` remains on `670a3e789540082437c7521cc5ad7a4c260f56ee8e0a9cf770160fa25b4e4644` unless a later checkpoint says otherwise.
## 2026-06-08 `.198` Podman Pull Hardening Deploy
- Deployed backend hash `a52a87474c9a788e058ee1da1edd6091ab305594a53e7a153889f77041598ff4` to `.198`.
- Previous backend was backed up under `/usr/local/bin/archipelago.backup-20260608-store-risk-*` before replacement.
- Restarted only `archipelago.service`; no host reboot was performed.
- No broad Podman store/image commands were run.
- Initial `systemctl restart` exceeded the local 120s wrapper while startup was still in progress, but the backend reached `Server listening`, then systemd settled to `active/running`.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `a52a87474c9a788e058ee1da1edd6091ab305594a53e7a153889f77041598ff4`.
- `/`: `65%` used, about `9.8G` free.
- `/var/lib/archipelago`: `10%` used, about `370G` free.
- Validation passed:
- `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=fedimint,immich,indeedhub,photoprism ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- `python3 scripts/check-app-catalog-drift.py --release --strict`.
- Remaining release gate: host reboot validation, only if explicitly approved.
## 2026-06-08 `.198` App Health and Port Recovery
- Deployed backend hash `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de` to `.198`.
- Fedimint Guardian and File Browser were reachable but UI package-data reported `health=starting`; backend scanner now normalizes reachable running apps to healthy and restores the launch URL when the direct port is reachable.
- Nostr relay had been using host port `8081`, which conflicted with Nginx Proxy Manager admin launch. Updated `apps/nostr-rs-relay/manifest.yml` to use host port `18081`.
- Recovered live Nostr/NPM state:
- Nginx Proxy Manager admin UI responds on `http://127.0.0.1:8081/`.
- Nostr relay responds on `http://127.0.0.1:18081/` with the expected Nostr-client message.
- Hardened legacy install runtime for scoped web apps: use `podman create` followed by `systemd-run --user --scope podman start` so containers are not coupled to `archipelago.service`, while install RPCs do not hang on scoped `podman run -d`.
- Recovered IndeedHub after broad validation found it stopped:
- `indeedhub-minio` had stopped, causing the frontend nginx container to exit with `host not found in upstream "minio"`.
- Restarted existing `indeedhub-minio` with preserved volume data and restarted the frontend.
- `http://127.0.0.1:7778/` returned HTTP `200` afterward.
- Validation passed:
- `cargo check --manifest-path core/Cargo.toml -p archipelago -p archipelago-container`.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release`.
- `python3 scripts/check-app-catalog-drift.py --release --strict`.
- Focused lifecycle for `indeedhub,nginx-proxy-manager,nostr-rs-relay,fedimint,filebrowser`.
- Broad non-destructive lifecycle: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de`.
- `/`: `65%` used, about `9.6G` free.
- `/var/lib/archipelago`: `10%` used, about `370G` free.
- Remaining release gate: host reboot validation, only if explicitly approved.
## 2026-06-04 `.198` IndeedHub and Immich Lifecycle Recovery
- Deployed backend hash `89dfc3d4e801b35564dc8dc7f4a513028eb7e2027b586e8aad7a0f374e20d6a9` to `.198`.
- Fixed IndeedHub frontend startup sequencing so network alias repair is only applied immediately before the frontend starts, after `indeedhub-minio`, `indeedhub-redis`, and `indeedhub-api` are running.
- Fixed Immich lifecycle recovery on `.198`:
- dependency readiness now accepts healthy Podman health state for `immich_postgres` and `immich_redis` before falling back to slower `podman exec` probes;
- `immich_server` startup now repairs `/var/lib/archipelago/immich` ownership through `podman unshare chown -R 0:0`, preserving existing upload data while matching the current rootless container user mapping;
- this resolved the observed `EACCES` failure writing `/usr/src/app/upload/encoded-video/.immich`.
- Diagnosis notes:
- Broad audit initially failed only on Immich (`state=exited`); focused Fedimint and NetBird audits passed.
- Patched dependency wait got lifecycle past dependencies to `Starting container: immich_server`.
- Upload ownership repair allowed Immich API and microservices to remain running; direct `http://127.0.0.1:2283/` returned HTTP `200`.
- Verification on this hash:
- `cargo check --manifest-path core/Cargo.toml -p archipelago` passed.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release` passed.
- Focused IndeedHub audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=indeedhub ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Focused Fedimint audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=fedimint ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=300 tests/lifecycle/remote-lifecycle.sh`.
- Focused NetBird audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=netbird ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=300 tests/lifecycle/remote-lifecycle.sh`.
- Focused Immich audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=immich ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive lifecycle audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state after validation:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `89dfc3d4e801b35564dc8dc7f4a513028eb7e2027b586e8aad7a0f374e20d6a9`.
- Residual risk:
- `.198` still shows intermittent `podman ps -a --format json timed out after 30s` and transient Bitcoin RPC timeouts under load; keep avoiding store-wide Podman commands and treat Podman socket/store health as a separate release hardening item.
## 2026-06-03 `.198` Generic Host-Port Health Checkpoint
- Latest local Podman store-risk mitigation, pending deploy to `.198`:
- `core/container/src/runtime.rs` now implements `ContainerRuntime::image_exists()` with bounded targeted `podman image inspect` instead of `podman image exists`.
- `core/archipelago/src/api/rpc/package/install.rs` now verifies local fallback images and post-pull images with bounded targeted `podman image inspect` instead of `podman images -q`.
- `core/archipelago/src/container/companion.rs` now uses `podman image inspect` instead of `podman image exists`.
- A grep across `core/**/*.rs` finds no live Rust call sites for `podman image exists` or `podman images -q`; only an explanatory comment remains.
- Validation passed: `cargo fmt --all --check`, `cargo check -p archipelago-container`, `cargo check -p archipelago`, `CARGO_INCREMENTAL=0 cargo check -p archipelago --tests`, `cargo test -p archipelago-container`, and whitespace check for the changed files.
- A filtered `cargo test -p archipelago install_fresh_build` did not reach execution due to local compile/link slowness/artifact failure; `--tests` compilation passed afterward.
- Deployed backend hash `14d360a206d1e58f287c5722d709dace0284b0dea56b66aa4bce0f57c631631b` to `.198` after release code-review/refactor cleanup of legacy runtime host-port repair.
- Reduced duplicated app-specific port repair logic in `core/archipelago/src/api/rpc/package/runtime.rs`:
- legacy package start/restart repair now derives host ports from `apps/*/manifest.yml` when available;
- hardcoded ports remain only as fallback for legacy/non-manifest apps and for extra legacy cleanup ports such as Gitea `3000` and Nginx Proxy Manager `8084`/`8444`;
- the old duplicate Gitea cleanup helper was removed;
- focused unit coverage was added for manifest-derived runtime ports and legacy extra ports.
- Verification on this hash:
- `cargo check --manifest-path core/Cargo.toml -p archipelago` passed.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release` passed.
- Focused `runtime_host_ports` test was added but local `cargo test ... runtime_host_ports` did not complete within 5 minutes during compilation, consistent with known local test/linker slowness.
- Targeted PhotoPrism audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=photoprism ARCHY_STABILITY_SECONDS=1 ARCHY_TIMEOUT=120 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive lifecycle audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state after validation:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `14d360a206d1e58f287c5722d709dace0284b0dea56b66aa4bce0f57c631631b`.
- Catalog metadata generation is now implemented:
- Added `scripts/generate-app-catalog.py` to sync manifest-owned fields into both `app-catalog/catalog.json` and `neode-ui/public/catalog.json` while preserving catalog-only presentation/runtime fields.
- Corrected stale manifest metadata for public catalog apps where the manifest was behind production catalog/image values: BotFights, IndeeHub, Gitea icon/repo, LND title/image, ElectrumX image, Fedimint image, and Mempool title/version/image.
- Ran generator; canonical and UI catalogs now match byte-for-byte.
- Release drift gate is green: `python3 scripts/check-app-catalog-drift.py --release --strict` reports `metadata_drift=0`, `missing_catalog=0`, `missing_manifests=0`.
- Validation passed: `jq empty app-catalog/catalog.json neode-ui/public/catalog.json`, `cargo test --manifest-path core/Cargo.toml -p archipelago-container`, `cargo check --manifest-path core/Cargo.toml -p archipelago`, and `npm run build` from `neode-ui`.
- Deployed backend hash `eaa83c30467acd42ad864a8e0ea0d5fd88b94b775a06bfcdc460c4b0cd8e75b2` to `.198` after a narrow Podman store-risk hardening pass.
- Hardened fresh local-build installs so `podman image exists <local-build-tag>` failures/timeouts no longer fail the lifecycle operation outright:
- existing timeout remains bounded in the runtime;
- `install_fresh()` now logs the check failure and rebuilds the local image instead;
- this matches the existing drift-restart path and keeps local image store checks from becoming release-blocking.
- Verification on this hash:
- `cargo check --manifest-path core/Cargo.toml -p archipelago` passed.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release` passed.
- Focused unit test `install_fresh_builds_when_image_exists_check_fails` was added but local `cargo test ...` did not complete within 15 minutes during compilation, consistent with known local test/linker slowness.
- Targeted PhotoPrism audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=photoprism ARCHY_STABILITY_SECONDS=1 ARCHY_TIMEOUT=120 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive lifecycle audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state after validation:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `eaa83c30467acd42ad864a8e0ea0d5fd88b94b775a06bfcdc460c4b0cd8e75b2`.
- Deployed backend hash `be95ea91339a7fb0a3b20d0ae5d816dca220d5e5ca86838cc0ba50b609ad7b36` to `.198` after hardening `container-health` fallback behavior.
- Fixed the broad lifecycle timeout path where `container-health` could return `Failed to get container health` even though the app endpoint was reachable:
- `cached_reachable_health()` now parses URL ports correctly when launch URLs include a trailing slash, such as `http://localhost:2342/`.
- The fallback port map now covers the lifecycle launch apps, including PhotoPrism `2342`, BTCPay `23000`, LND UI `18083`, Mempool `4080`, Electrum `50002`, Fedimint `8175`, Gitea `3001`, IndeedHub `7778`, Ollama `11434`, Vaultwarden `8082`, Tailscale `8240`, and others.
- Reachable cached-running apps can now return `healthy` without depending on flaky Podman health/inspect paths.
- Verification on this hash:
- `cargo check --manifest-path core/Cargo.toml -p archipelago` passed.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release` passed.
- Targeted PhotoPrism audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=photoprism ARCHY_STABILITY_SECONDS=1 ARCHY_TIMEOUT=120 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive lifecycle audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state after validation:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `be95ea91339a7fb0a3b20d0ae5d816dca220d5e5ca86838cc0ba50b609ad7b36`.
- `/`: `62%` used, about `11G` free.
- `/var/lib/archipelago`: `9%` used, about `370G` free.
- Remaining blockers:
- Podman socket/store health is still a release risk; continue avoiding broad store/image commands on `.198`.
- Backend-restart and host-reboot validation are still pending and should be run only when approved.
## 2026-06-03 `.198` Generic Host-Port Health Checkpoint In Progress
- Deployed backend hash `3912b900c376b6c28bf5453640cae82135f67d7e0f984b8adcc78064b924143b` to `.198`.
- This pass is explicitly aligned with the migration objective: use generic platform primitives from manifest/container-declared ports instead of adding more OS-level or app-specific package edits.
- Broad lifecycle on previous hash `d21202cd...` failed only because Uptime Kuma briefly appeared as `stopping` during listener repair; it recovered immediately afterward with `3002` listening and HTTP `302`.
- Implemented generic health-monitor host-port awareness:
- Health monitor now parses Podman JSON `Ports` host TCP bindings for each container.
- A running container with declared host TCP ports is not considered healthy if those host listeners are missing.
- This avoids a hardcoded app-to-port list and makes missing pasta/rootless listeners a generic recovery concern.
- Also fixed scanner merge semantics:
- `Stopping -> Running` now recovers immediately when there is no user-stopped marker.
- User-initiated stops still preserve `Stopping` over live `Running` while the stop is in progress.
- Verification so far:
- `cargo check --manifest-path core/Cargo.toml -p archipelago` passed.
- `cargo build --manifest-path core/Cargo.toml -p archipelago --bin archipelago --release` passed.
- Live service state after deploy: `archipelago.service` active; doctor/reconcile timers inactive.
- After backend restart, Uptime Kuma recovered its `3002` listener and returned HTTP `302`.
- Still in progress:
- Jellyfin is still running/healthy according to Podman but missing the `8096` host listener after backend restart.
- Next fix should keep the same generic direction: missing host listener repair should use the manifest/orchestrator-aware restart path for apps with declared ports, not another Jellyfin-specific OS edit.
- Broad lifecycle has not yet passed on `3912b900...`.
## 2026-06-03 `.198` Stale State and Jellyfin Pasta Listener Repair
- Deployed backend hash `d21202cd79794e3bfc882d37134afd7a41dac766bae386a675714e5fa030e94e` to `.198`.
- Fixed a focused lifecycle false-negative where `container-list` could report stale cached `exited` state while Podman scan backoff was active and the container had already recovered:
- Cached `exited` entries now get a targeted live refresh before being returned by `container-list`.
- This avoids broad `podman ps` scans and preserves the UI/package-data consistency model.
- Added a bounded `container-health` fallback for cached running web apps:
- If the cached app state is `Running` and its known local launch port accepts TCP, the RPC can return `healthy` without waiting on Podman inspect/list paths.
- This quarantines health reads from intermittent Podman socket/store stalls.
- Added Jellyfin to the legacy runtime host-port repair path:
- `runtime_required_host_port("jellyfin")` now maps to `8096`.
- stale pasta cleanup now includes `8096` for Jellyfin start conflicts.
- Validation notes:
- `package.restart jellyfin` exposed a remaining Podman socket/runtime failure after stopping the container: `Cannot connect to Podman socket at /run/user/1000/podman/podman.sock: Permission denied`.
- `package.start jellyfin` recovered the app afterward; `jellyfin` returned `Up ... (healthy)`, `ss` showed a `pasta.avx2` listener on `8096`, and `http://192.168.1.198:8096/` returned HTTP `302`.
- Focused lifecycle passed on the current hash: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser,uptime-kuma ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Endpoint checks after focused lifecycle: Uptime Kuma `3002` returned `302`; Jellyfin `8096` returned `302`; Filebrowser `8083` returned `404` at `/`, which is expected for this probe.
- `scripts/check-app-catalog-drift.py --release` still reports zero missing entries and `35` metadata drift items.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `d21202cd79794e3bfc882d37134afd7a41dac766bae386a675714e5fa030e94e`.
- `/`: `62%` used, about `11G` free.
- `/var/lib/archipelago`: `9%` used, about `371G` free.
- Remaining blocker:
- Broad lifecycle has not yet been rerun on `d21202cd...`.
- Podman socket/store health is still a release risk; avoid broad image/store commands and treat socket permission/runtime failures separately from app health.
## 2026-06-03 `.198` Expanded Rollback Cleanup and Store-Safe Uninstall
- Deployed backend hash `7f90345b75148b7ed748e1a417f31d1273e1646a9b742891858df11c5397051b` to `.198`.
- Expanded `system.disk-cleanup` retention beyond `archipelago.backup-*` to cover alpha-era rollback artifacts:
- legacy `/usr/local/bin/archipelago.bak*` and `archipelago.before-*` files;
- old `/opt/archipelago/web-ui.bak*` and `web-ui.old` directories.
- Live cleanup reclaimed `10.3 GB` without touching Podman image/volume prune:
- `Removed old backend backups: 41.6 MB freed`.
- `Removed old legacy backend backups: 3.6 GB freed`.
- `Removed old web UI backups: 6.6 GB freed`.
- `Skipped Podman image/volume prune: Podman store commands can block app health on busy nodes`.
- Root filesystem pressure is no longer a release blocker on `.198`:
- Before expanded cleanup: `/` was `99%` used with about `478-545M` free.
- After expanded cleanup: `/` is `61%` used with about `11G` free.
- `/usr/local/bin` dropped to about `336M`; `/opt/archipelago` dropped to about `1.1G`.
- Uninstall no longer runs global `podman volume prune -f`; app data removal remains explicit when `preserve_data=false`.
- Verification:
- `cargo build -p archipelago --bin archipelago --release` passed.
- Local `cargo test -p archipelago system::tests` did not complete within 10 minutes in this environment; release build succeeded and live cleanup validation passed.
- Focused post-cleanup lifecycle passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser,uptime-kuma ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `7f90345b75148b7ed748e1a417f31d1273e1646a9b742891858df11c5397051b`.
## 2026-06-03 `.198` Startup Scan Backoff and Uptime Kuma Pasta Repair
- Deployed backend hash `2b72e83ff368e4a696ad701f8985b0a8e1e889d9f4844056dc063455df973b28` to `.198`.
- Startup adoption is now bounded with a 35s timeout so a stuck `podman ps -a --format json` cannot stall backend startup indefinitely.
- The initial container scan now seeds the same 300s Podman scan backoff used by periodic scans, preventing an immediate second `podman ps` after a startup timeout.
- Legacy pasta restart paths now use scoped `podman restart` instead of stop+start. This repairs cases where a running pasta container loses its host listener but `podman start` would be a no-op.
- Uptime Kuma validation:
- Before repair, the container was running and internally healthy on `127.0.0.1:3001`, but host port `3002` had no `pasta` listener and LAN launch failed.
- `package.restart` for `uptime-kuma` now returns `{"status":"restarted"}` instead of hanging.
- Post-restart `http://192.168.1.198:3002/` returned HTTP `302` and the scanner restored launch metadata.
- Release validation passed:
- Focused audit: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser,uptime-kuma ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Broad audit: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final `.198` state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/`: still tight at `99%` used, about `395M` free.
- `/var/lib/archipelago`: about `10%` used.
- Residual risk:
- `.198` Podman store health remains fragile under broad store commands; avoid prune/image-list/system-df release operations until the store issue is handled separately.
- Logs during broad validation still showed unrelated IndeedHub/conmon cgroup permission noise, but focused and broad lifecycle audits passed.
## 2026-06-02 `.198` Registry/Catalog and Lifecycle Checkpoint
- Follow-up on Podman prune/catalog generation:
- Diagnosed the `podman image prune -f` failure and found it is broader than prune: `podman system df`, `podman image list`, `podman image exists`, and sometimes broad `podman ps`/`inspect` can hang on `.198` under current store/node load.
- Stopped only the diagnostic Podman commands started during this follow-up.
- Changed `system.disk-cleanup` to skip Podman image/volume prune entirely for the release path. Cleanup still handles logs, journal retention, temp files, and backend backup retention, and returns an explicit action: `Skipped Podman image/volume prune: Podman store commands can block app health on busy nodes`.
- Deployed backend hash `c9695dc3db10ff6e593cdbcfbbdc94b2e98b6008aa62655bba51b9879b549e8c` to `.198`.
- Live cleanup validation passed: endpoint returned quickly, pruned old backend backups, did not spawn new Podman prune/list work, and `/` stayed around `98%` with about `647-670M` free.
- During diagnosis, Uptime Kuma's port returned empty responses. Restarted only `uptime-kuma` through `package.restart`; data preserved; launch returned HTTP `302` afterward.
- Focused post-repair audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser,uptime-kuma ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Broad post-repair audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final raw Podman bad-state sweep was clean.
- Catalog metadata generation is not implemented yet. The release-safe step in this pass is the new `scripts/check-app-catalog-drift.py --release` mode, which reports zero missing catalog/manifest entries while still surfacing metadata-only drift.
- Release-work continuation after cleanup/catalog/review gate:
- Deployed backend hash `e285d421cef497beb6b4b929f36fb4296d6db1f4a4c786157b6751eec51619ca` to `.198`.
- `system.disk-cleanup` is now bounded so a slow `podman image prune -f` cannot wedge the cleanup RPC indefinitely; the prune failure is reported as an action while cleanup continues.
- `system.disk-cleanup` now vacuums systemd journals to a bounded size and prunes timestamped `/usr/local/bin/archipelago.backup-*` files to the newest three using the existing `host_sudo` path.
- Live cleanup validation passed: endpoint returned, journals were reduced to about `200M`, old backend backups were pruned to three, and `/` improved from about `99%`/`490M` free to `98%`/about `730M` free.
- Added `nostr-rs-relay` to both catalog surfaces. Release-focused catalog drift now has zero missing catalog/manifest entries; remaining drift is metadata-only and belongs to the catalog-generation follow-up.
- Focused post-cleanup audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser,nostr-rs-relay,portainer ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Broad post-cleanup audit passed with extended harness timeout: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Final raw Podman sweep showed no `unhealthy`, `stopping`, `removing`, `exited`, `created`, or `initialized` containers.
- Final service state: `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive.
- Follow-up validation after the previous cutoff:
- `.198` is already running the current local release build hash `579b823cf4a4b8c50bb3d0c3d49449c58101b016eb6ebc8049975dce98e34265`; no backend replacement was performed in this pass.
- Local release binary smoke-started successfully on an alternate bind/data dir before live checks.
- Meshtastic manifest-owned file rendering is now proven live: `/var/lib/archipelago/meshtastic/config.yaml` was backed up, removed, and recreated by `package.restart` from `apps/meshtastic/manifest.yml`.
- Focused Meshtastic audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=300 tests/lifecycle/remote-lifecycle.sh`.
- Focused regression audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Final raw Podman sweep showed no `unhealthy`, `stopping`, `removing`, `exited`, `created`, or `initialized` containers.
- Service state remains deterministic-test safe: `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive.
- `/` remains tight at `99%` used with about `490M` free.
- Live `.198` state after this pass:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256 is now `579b823cf4a4b8c50bb3d0c3d49449c58101b016eb6ebc8049975dce98e34265`; no backend replacement was performed in this follow-up pass.
- `/`: still tight at `99%` used, about `490M` free.
- Registry state:
- Live `/var/lib/archipelago/config/registries.json` is already correct: `146.59.87.168:3000/lfg2025` is primary with `tls_verify: false`; `git.tx1138.com/lfg2025` is enabled as secondary with `tls_verify: true`.
- Added `meshtastic` and `portainer` to both `app-catalog/catalog.json` and `neode-ui/public/catalog.json` so migrated manifest-owned apps are present in the registry/catalog surface.
- Live recovery performed:
- Raw Podman sweep found `nextcloud` stuck in `Removing`.
- Removed only the wedged container record with `podman rm -f nextcloud`; bind-mounted data was preserved.
- Local verification passed:
- `jq empty app-catalog/catalog.json neode-ui/public/catalog.json`.
- `cargo test -p archipelago-container generated_files_must_live_under_bind_mounts`.
- `cargo test -p archipelago manifest_generated_files`.
- `cargo test -p archipelago reconcile_force_recreates_stopping_container`.
- `cargo test -p archipelago health_maps_states_to_strings`.
- `cargo test -p archipelago test_rewrite_image`.
- `cargo test -p archipelago test_load_default`.
- `cargo check -p archipelago --bin archipelago`.
- `cargo build -p archipelago --bin archipelago --release`, hash `13786fd7bc5afb36fb7873ad9aee1a54a696e75b0a92c2fcd90cc8100038a54c`.
- Live validation passed:
- Focused audit: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive audit: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Final raw Podman sweep showed no `unhealthy`, `stopping`, `removing`, `exited`, `created`, or `initialized` containers.
- Remaining before release:
- The prior release-binary segfault is no longer reproducing with the current artifact; `.198` is active on hash `579b823cf4a4b8c50bb3d0c3d49449c58101b016eb6ebc8049975dce98e34265`. Continue watching logs after restarts, but do not treat `app.files` deployment as blocked.
- Add disk cleanup/backup retention policy; root filesystem pressure still makes deploys and image operations fragile.
- Resolve broader app catalog/manifest drift reported by `scripts/check-app-catalog-drift.py`; this pass only added the migrated Meshtastic and Portainer catalog entries.
## 2026-05-28 `.198` Meshtastic File-Rendering Recovery Checkpoint
- Current `.198` service state after recovery:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256 restored to `2ec1952dcc5f6101d236dd3ea7a85a40a6387a3f1afb8a5681345cad90306853` after a failed deploy attempt.
- `/`: still tight at `99%` used, about `546M` free.
- Local generated-file support status:
- Manifest schema supports `app.files`.
- Production orchestrator writes declared manifest files before create/start/restart and does not overwrite existing files unless `overwrite: true` is declared.
- Meshtastic manifest declares `/var/lib/archipelago/meshtastic/config.yaml` under its bind-mounted data directory.
- Local verification passed:
- `cargo test -p archipelago-container generated_files_must_live_under_bind_mounts`.
- `cargo test -p archipelago manifest_generated_files`.
- `cargo check -p archipelago --bin archipelago`.
- `cargo build -p archipelago --bin archipelago --release` produced local hash `13786fd7bc5afb36fb7873ad9aee1a54a696e75b0a92c2fcd90cc8100038a54c`.
- Live deploy caveat:
- Deploying the local release binary to `.198` caused immediate `SIGSEGV` on `archipelago.service` startup.
- The previous live binary was restored from `/usr/local/bin/archipelago.backup-20260528-container-files-2ec1952dcc5f6101d236dd3ea7a85a40a6387a3f1afb8a5681345cad90306853`; backend returned active.
- Do not redeploy that local release artifact blindly; diagnose the startup segfault/build mismatch first.
- Live Meshtastic recovery:
- Before recovery, `.198` had Meshtastic manifests with `files:` but no `/var/lib/archipelago/meshtastic/config.yaml`; container logs showed `No 'config.yaml' found` and `Blank MAC Address not allowed`.
- Wrote the same config currently declared by the manifest to `/var/lib/archipelago/meshtastic/config.yaml` as an operational recovery, then restarted `meshtastic.service`.
- Meshtastic returned `Up ... (healthy)`.
- Live validation passed:
- `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=meshtastic,jellyfin,filebrowser ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Raw Podman sweep showed Meshtastic, Jellyfin, File Browser, BTCPay, Grafana, SearXNG, Gitea, Nostr relay, Botfights, Portainer, Nginx Proxy Manager, and other active managed containers without unhealthy/stopping/removing/exited states.
- Next required work:
- Diagnose why the local release backend segfaults immediately on `.198` before deploying the generic manifest file renderer as the durable fix.
- After a safe backend deploy, remove reliance on the manually recovered Meshtastic config by proving the manifest-owned renderer recreates it on start/restart.
- Keep deterministic-test timers inactive unless intentionally running non-deterministic recovery testing.
## 2026-05-27 `.198` Manifest-Orchestrator Migration Checkpoint
- Current `.198` live backend:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `31ae1b346fd36d715c9fe7f0686dcb31a70d2fea44996abf122743d048fb7b2f`.
- Migration goal confirmed and advanced: apps should not require hardcoded OS/Rust edits to work. App differences belong in manifests; Rust/OS should provide generic primitives for lifecycle, Quadlet rendering, readiness/health, port repair, bind-mount prep, data ownership, and image availability.
- New generic backend fixes deployed:
- Quadlet health drift detection now compares `HealthCmd`, `HealthInterval`, `HealthTimeout`, and `HealthRetries`.
- HTTP health command rendering now derives `wget -T` / `curl -m` from manifest `health_check.timeout`; `timeout: 30s` now produces helper-level `30s` probes instead of an outer Podman `30s` wrapped around an inner `5s` command.
- Existing Quadlet unit drift that requires restart now verifies the manifest image exists locally and pulls/builds if missing before restarting.
- Existing Quadlet service start for a missing container now also verifies/pulls/builds the manifest image before `systemctl --user start`.
- Reconcile now treats manifest-declared dependencies of active apps as required even if stale `user-stopped.json` entries exist, and parent app reconcile drift-syncs existing dependency Quadlet units from their own manifests.
- Portainer host prep moved out of a hardcoded Rust install hook; generic bind-mount socket prep now handles manifest sources ending in `/podman.sock`.
- Manifest updates deployed to both `/opt/archipelago/apps` and `/opt/archipelago/web-ui/archipelago-runtime/apps`:
- `portainer`: declarative manifest with data dirs, Podman socket mount, capabilities, `data_uid`, `9000:9000`, and no Podman healthcheck.
- `btcpay-server`, `grafana`, `nostr-rs-relay`, `searxng`: HTTP health timeouts/retries loosened to `timeout: 30s`, `retries: 5` to avoid false negatives under `.198` load.
- `archy-nbxplorer` manifest has `timeout: 30s`, `retries: 5`; live unit now matches with helper-level `wget -T 30` / `curl -m 30`.
- Local verification passed:
- `cargo fmt`.
- `cargo test -p archipelago translate_health_check -- --nocapture` passed.
- `cargo check -p archipelago --bin archipelago` passed after each backend fix.
- `cargo build -p archipelago --bin archipelago --release` passed; final deployed binary hash is `31ae1b346fd36d715c9fe7f0686dcb31a70d2fea44996abf122743d048fb7b2f`.
- Live `.198` validation:
- Portainer full lifecycle passed earlier: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=portainer ARCHY_FULL_LIFECYCLE=1 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- BTCPay focused lifecycle passed after the missing-image start guard: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=btcpay-server ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Focused migration audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=btcpay-server,grafana,nostr-rs-relay,searxng,portainer,gitea ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Broad non-destructive lifecycle audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=600 tests/lifecycle/remote-lifecycle.sh`.
- Targeted unit/container sweep showed `btcpay-server`, `grafana`, `nostr-rs-relay`, `searxng`, and `portainer` services active.
- Post-focused and post-broad raw Podman sweeps found no `unhealthy`, `stopping`, `removing`, `exited`, `created`, or `initialized` containers.
- Raw states: `btcpay-server Up ... (healthy)`, `grafana Up ... (healthy)`, `nostr-rs-relay Up ... (healthy)`, `searxng Up ... (healthy)`, `portainer Up ...`.
- Generated units for `btcpay-server`, `grafana`, `nostr-rs-relay`, and `searxng` now show helper-level `wget -T 30` / `curl -m 30`, `HealthTimeout=30s`, and `HealthRetries=5`.
- Generated unit for `archy-nbxplorer` now also shows helper-level `wget -T 30` / `curl -m 30`, `HealthTimeout=30s`, and `HealthRetries=5`; BTCPay stack remained healthy.
- Filebrowser full lifecycle passed under the manifest/orchestrator path: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=filebrowser ARCHY_FULL_LIFECYCLE=1 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Filebrowser post-test live verification: `filebrowser.service` active; bind mounts `/srv` and `/data` rendered; `Exec=--config /data/.filebrowser.json`; generated `.filebrowser.json` points database to `/data/filebrowser.db` and root to `/srv`; container is `Up ... (healthy)`.
- Operational caveat found:
- `.198` root filesystem remains tight: about `556M` free on `/` (`99%` used). There are many old backend backup binaries under `/usr/local/bin`; deploys and Podman image operations are fragile until backup/image cleanup policy is added.
- Remaining before release:
- Meshtastic full lifecycle now passed on `.198` after routing it through the orchestrator path and fixing its manifest image, device, volume target, health check, launch metadata handling, and TCP port declaration.
- Replace the temporary/manual Meshtastic host `config.yaml` dependency with the generic manifest-owned file rendering path:
- Added local schema support for `app.files`.
- Added local production-orchestrator rendering for declared files before container start.
- Added Meshtastic `files:` declaration for `/var/lib/archipelago/meshtastic/config.yaml`.
- Local manifest parser tests passed; backend orchestrator tests are still running before deployment.
- Latest post-Meshtastic raw `.198` sweep:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/`: 99% used, about `532M` free.
- `jellyfin` and `filebrowser` reported `unhealthy`; investigate before final release qualification.
- Add the release code-review/refactor/performance gate: remove dead transitional code, reduce remaining app-specific Rust/OS paths, review scan/health/reconcile performance, then rerun lifecycle and launch tests after cleanup.
## 2026-05-26 Migration Release Notes
- Active doctrine: app-specific host mutations should move out of generic Rust/OS install paths wherever possible. Apps should be described by manifests and lifecycle hooks; the Rust backend should provide generic primitives for validation, container lifecycle, health/readiness, port repair, secrets, data ownership, and recovery.
- Current `.198` work remains focused on lifecycle migration hardening first. Do not call the migration finished until focused full lifecycle and broad audits pass on the manifest/orchestrator-owned path.
- `.198` Gitea migration checkpoint:
- Backend deployed: `/usr/local/bin/archipelago` sha256 `3780e54eec4821a61fbc024259bd854ec376228eb981fa169ec6f8aeafc5a9dd`.
- Gitea manifest deployed to both `/opt/archipelago/apps/gitea/manifest.yml` and `/opt/archipelago/web-ui/archipelago-runtime/apps/gitea/manifest.yml`, latest sha256 `8df263fcca9581a4e0a2872d21d26eed35b007c7bd7475071bedfd005f514e68`.
- The Gitea fix is manifest-owned: `security.no_new_privileges` is now honored by the generic Podman/Quadlet renderers, and Gitea declares its required capabilities (`CHOWN`, `FOWNER`, `SETUID`, `SETGID`, `DAC_OVERRIDE`, `NET_BIND_SERVICE`) plus `no_new_privileges: false`.
- Focused full lifecycle passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=gitea ARCHY_FULL_LIFECYCLE=1 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- `.198` generic host-listener repair checkpoint:
- Backend deployed: `/usr/local/bin/archipelago` sha256 `be06756763283535d2b3ee911cc91c7d401fb51b4dd88a3ebe86d79a05183e84`.
- Running-container reconcile now probes manifest-declared host ports and repairs missing listeners generically; observed repair restored Grafana port `3000` without a Grafana-specific OS edit.
- Uptime Kuma repair uses a longer readiness window so the generic repair path does not restart it before its slow HTTP startup completes.
- Gitea healthcheck timeout/retries were loosened in manifest metadata (`timeout: 30s`, `retries: 5`) after raw Podman health showed timeout-only false negatives while HTTP launch returned `200`.
- Focused audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=gitea,grafana,uptime-kuma ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=300 tests/lifecycle/remote-lifecycle.sh`.
- Release follow-ups to keep in scope after the current Gitea/Uptime/Nextcloud migration pass:
- Portainer fixes discussed on 2026-05-26 must be carried into the new declarative approach, not left as a hardcoded OS prerequisite path. Completed for the current `.198` pass:
- Added `apps/portainer/manifest.yml` with manifest-declared data dirs, Podman socket mount, port `9000`, capabilities, `data_uid`, and no Podman healthcheck.
- Removed the hardcoded `ensure_portainer_host()` OS/Rust install hook.
- Added generic manifest-driven Podman socket preparation for any app that bind-mounts `podman.sock`.
- Backend deployed: `/usr/local/bin/archipelago` sha256 `d440e2cba52c6e1b60d8f0716386b0f4e3ce56b5370cedafabc6dbd30d230909`.
- Portainer manifest deployed to both `/opt/archipelago/apps/portainer/manifest.yml` and `/opt/archipelago/web-ui/archipelago-runtime/apps/portainer/manifest.yml`, latest sha256 `5e2ab96f2ba91ad2539a7dc6b73c92c6cece676109550d7d4c2f556aa578ba9c`.
- Focused full lifecycle passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=portainer ARCHY_FULL_LIFECYCLE=1 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=900 tests/lifecycle/remote-lifecycle.sh`.
- Re-test the Filebrowser fixes under the manifest/orchestrator path.
- Re-test the Meshtastic fixes before final release qualification.
- Add an app packaging documentation gate: update `docs/APP-PACKAGING-MIGRATION-PLAN.md` and `docs/app-developer-guide.md` so third-party developers can package apps against the current manifest/runtime contract without relying on one-off OS-level changes.
- Add a required release code-review/refactor gate before cutting `1.8-alpha`: remove dead transitional code, replace remaining app-specific Rust/OS paths with manifest-owned metadata or generic lifecycle primitives, review scan/health/reconcile performance, then rerun lifecycle and launch tests after the cleanup.
## 2026-05-13 `.198` Stopping-State Repair Checkpoint
+216
View File
@@ -0,0 +1,216 @@
# Current Agent Handoff - Bitcoin UI Recovery And `1.8-alpha` Resume
Last updated: 2026-06-10 05:33 EDT
## Read This First
This is a separate handoff from `docs/NEXT_TERMINAL_HANDOFF.md`. That file tracks
an older/broader plan. For the next agent resuming this machine-switch pause,
read this file first, then read:
- `docs/RESUME.md`
- `docs/1.8-alpha-improvements-tracker.md`
- `docs/CONTAINER_LIFECYCLE_HANDOFF.md`
- `docs/MIGRATION_STATUS_REPORT.md`
Do not assume `docs/NEXT_TERMINAL_HANDOFF.md` is the current short-term plan.
## Current Goal
Cut Archipelago `1.8-alpha`, including a ready-to-test ISO image.
The release goal is not just "apps launch once"; the app/container system needs
to be developer-ready and production-release ready:
- manifests and docs must describe the real runtime contract;
- apps must install, start, stop, restart, uninstall, reinstall, survive reboot,
report truthful status, and show useful progress;
- My Apps must preserve last-known truth during Podman/scanner backoff instead
of showing false empty/no-app states;
- Bitcoin-dependent apps must explain sync/wallet readiness instead of looking
broken;
- final validation needs focused lifecycle, broad non-destructive lifecycle,
then repeated reboot checks before ISO cut/smoke test.
## Current Estimate
As of this pause:
- Credible release candidate: roughly `87-91%`.
- Production-quality release developers will love: roughly `73-79%`.
- Calendar estimate if the remaining systemic lifecycle issues are bounded:
`1-2 focused engineering days` for a release candidate, then additional
reboot/ISO smoke time.
- The biggest remaining risk is not catalog wiring; it is rootless Podman
control-plane responsiveness, stale scanner state, lifecycle progress UX, and
reboot validation.
## Validation Host
- Host: `192.168.1.198`
- SSH user: `archipelago`
- Password used in this session: `password123`
- Active Bitcoin app on this host: `bitcoin-knots`, not `bitcoin-core`
- Keep `archipelago-doctor.timer` and `archipelago-reconcile.timer` inactive
for deterministic validation unless intentionally testing them.
- Preserve app data.
- Avoid broad Podman store/image cleanup commands on `.198`.
## Bitcoin UI Incident Summary
User reported the Bitcoin custom UI showing:
`Bitcoin node is starting or busy syncing; retrying automatically. Detail:
getblockchaininfo: Bitcoin RPC request failed ... operation timed out`
Then after listener repair, the message changed through:
- `Connection refused`
- `Verifying blocks...`
- then the user reported it looked fine again.
What happened:
- The node is a `bitcoin-knots` node.
- During live debugging, the wrong alias, `bitcoin-core`, was started/stopped.
- `bitcoin-core` and `bitcoin-knots` compete for the same Bitcoin RPC/P2P ports.
- That action left the real `bitcoin-knots` service active but without the host
`8332` rootlessport listener for a while.
- Stopping the stray `bitcoin-core.service` and restarting only
`bitcoin-knots.service` recreated listeners on `8332` and `8333`.
- After restart, bitcoind entered the normal `-28 Verifying blocks...` phase.
- The user later reported the Bitcoin UI looked fine again.
Known live state observed during recovery:
- `bitcoin-knots.service`: active
- `bitcoin-core.service`: inactive
- `archy-bitcoin-ui.service`: active
- listeners present after repair:
- `8332` via `rootlessport`
- `8333` via `rootlessport`
- `8334` via nginx/Bitcoin UI
- `bitcoin-knots` logs showed active IBD around height `4137xx` and progress
about `0.09438`.
Do not restart Bitcoin again unless there is a fresh confirmed service/listener
failure. If checking status, prefer read-only probes and avoid starting the
wrong variant.
## Source Fixes Made Locally
These local edits were made after live Bitcoin recovered. They are not deployed
yet and were not fully validated before the user paused.
### `core/archipelago/src/bitcoin_status.rs`
Changed Bitcoin status cache behavior and copy:
- refresh interval changed from `5s` to `10s`;
- transient error backoff added at `15s`;
- RPC client timeout increased from `8s` to `20s`;
- error context now uses full anyhow chain with `{e:#}`;
- transient classifications now include common overloaded/backend states;
- user-facing copy now distinguishes:
- `verifying blocks after restart`;
- `waiting for the Bitcoin RPC listener`;
- `busy and not answering RPC before the timeout`;
- generic `starting or busy syncing`;
- added unit tests for the three user-visible states above.
Intent: stop collapsing distinct backend states into the same stale
"starting or busy syncing" timeout message.
### `core/archipelago/src/api/rpc/package/update.rs`
Narrow Bitcoin alias fix added:
- `orchestrator_update_app_id("bitcoin-knots")` now remains
`"bitcoin-knots"` instead of mapping to `"bitcoin-core"`;
- candidate app IDs for a Bitcoin container now prefer `bitcoin-knots` before
`bitcoin-core`;
- tests updated to lock this behavior.
Intent: `bitcoin-core` and `bitcoin-knots` can be dependency/status aliases,
but must not be interchangeable lifecycle/update targets on a node that has a
specific installed variant.
Important: this file also already contained other uncommitted update/pull
timeout changes from prior work. Do not assume every diff in this file came
from this interruption.
## Validation Status At Pause
Completed:
- `cargo fmt --manifest-path core/Cargo.toml --all` passed after the local
Bitcoin edits.
Attempted but not completed:
- Targeted Cargo tests were first launched in three separate `/tmp` target dirs
and failed due `/tmp` filling with `No space left on device`.
- Those temporary dirs were removed:
- `/tmp/archy-cargo-bitcoin-status`
- `/tmp/archy-cargo-update-alias`
- `/tmp/archy-cargo-container-candidates`
- A second run using `CARGO_TARGET_DIR=.codex-tmp/cargo-bitcoin-fix` was still
compiling when the user paused. It was terminated for handoff.
- No successful Rust test result exists yet for the new Bitcoin status/alias
tests.
Recommended validation after resume:
```bash
git diff --check -- core/archipelago/src/bitcoin_status.rs core/archipelago/src/api/rpc/package/update.rs docs/CURRENT_AGENT_HANDOFF.md
CARGO_TARGET_DIR=.codex-tmp/cargo-bitcoin-fix CARGO_BUILD_JOBS=2 cargo test --manifest-path core/Cargo.toml -p archipelago bitcoin_status::tests
CARGO_TARGET_DIR=.codex-tmp/cargo-bitcoin-fix CARGO_BUILD_JOBS=2 cargo test --manifest-path core/Cargo.toml -p archipelago update_aliases_map_to_manifest_app_ids
CARGO_TARGET_DIR=.codex-tmp/cargo-bitcoin-fix CARGO_BUILD_JOBS=2 cargo test --manifest-path core/Cargo.toml -p archipelago container_name_candidates_cover_common_aliases
```
If Cargo target locking appears stale, check for real `cargo`/`rustc` workers
before deleting anything. Prefer workspace-local target dirs under `.codex-tmp`
over new cold `/tmp` targets.
## Immediate Next Steps
1. Confirm no lingering Cargo process:
```bash
pgrep -af "cargo|rustc|cargo-bitcoin-fix"
```
2. Validate the local Bitcoin source fixes listed above.
3. If validation passes, build/deploy the backend to `.198` only after
confirming the user still wants deployment.
4. Recheck live Bitcoin non-destructively:
- `bitcoin-knots.service` active;
- `bitcoin-core.service` inactive;
- listeners on `8332`, `8333`, `8334`;
- Bitcoin UI loads on `8334`;
- `/bitcoin-status` returns useful copy if backend is busy.
5. Resume release backlog:
- rootless Podman lifecycle/control-plane responsiveness;
- My Apps last-known-state truthfulness during scanner backoff;
- progress UX for install/uninstall/start/stop/restart;
- remaining tracker rows in `docs/1.8-alpha-improvements-tracker.md`;
- focused lifecycle matrix on `.198`;
- broad non-destructive lifecycle;
- 3 clean reboot validations minimum, 5 preferred;
- ISO cut and ISO smoke test.
## Cautions For Next Agent
- Do not start `bitcoin-core` on `.198` unless intentionally migrating variants.
- Treat `bitcoin-knots` as the installed Bitcoin variant.
- Do not run broad Podman prune/store cleanup.
- Do not revert unrelated dirty worktree changes.
- `docs/NEXT_TERMINAL_HANDOFF.md` exists but is not the short-term handoff for
this pause.
- Many repo files are dirty from broader release hardening. Read diffs before
attributing changes.
+105
View File
@@ -0,0 +1,105 @@
# Migration Status Report
Last updated: 2026-06-11
## Goal
Make Archipelago's app/container system developer-ready and release-ready: app installs, lifecycle, recovery, and integrations should be portable, manifest-driven, and not rely on one-off OS-level changes or hardcoded Rust branches for each new app. The OS/backend should provide generic primitives for manifests, Quadlet rendering, lifecycle, health/readiness, dependency ordering, data ownership, image availability, bind mounts, secrets, app files, networking, bridge/signer integrations, and recovery.
The developer contract should be clear enough that a third-party developer can build and ship an Archipelago app from documentation plus manifest/schema examples. If an app needs a capability the platform does not yet expose, the release direction is to add a reusable manifest/orchestrator primitive rather than a special case tied to that app. This is the standard for the `1.8-alpha` app migration: professional app delivery, predictable behavior after restart/reboot, and a path for user-installed/community apps that does not require rebuilding the OS image for every app.
Release quality bar: every supported app must install, stop, start, restart, uninstall, survive host reboot, report accurate status, and expose clear install/uninstall progress. Stale health notifications must not persist across login or refresh after the underlying condition has cleared. Final release validation should run on the intended release validation server, not drift between appliances without an explicit checkpoint.
Target release: `1.8-alpha`, including a cut and smoke-tested ISO once validation is green.
Current release readiness estimate: about `82%`. The remaining percentage is mostly post-reboot recovery confidence, repeated reboot validation, and ISO creation/smoke testing rather than the core manifest/catalog migration itself.
## Current Result
- The migration is not final-release complete yet, but the core direction is being met.
- Portainer, Filebrowser, BTCPay, Grafana, Nostr Relay, SearXNG, Gitea, and key dependency units have moved further into the manifest/orchestrator path.
- `.198` has passed focused and broad lifecycle audits for the already migrated set.
- Meshtastic is now routed through the orchestrator path, no longer falls back to legacy `localhost/meshtastic:latest`, and has passed full lifecycle validation on `.198`.
- On 2026-06-02, focused and broad `.198` non-destructive lifecycle audits passed after clearing a wedged `nextcloud` Podman record. The live registry config already has OVH primary plus tx1138 mirror, and Meshtastic/Portainer were added to the catalog surfaces.
- Later on 2026-06-02, the current release backend hash `579b823cf4a4b8c50bb3d0c3d49449c58101b016eb6ebc8049975dce98e34265` was found active and stable on `.198`. Meshtastic `app.files` rendering was proven live by removing `/var/lib/archipelago/meshtastic/config.yaml`, restarting through `package.restart`, and verifying the manifest recreated the file. Focused Meshtastic, focused `meshtastic,jellyfin,filebrowser`, and broad non-destructive audits all passed afterward; raw Podman sweep was clean.
- The remaining release gate was continued on 2026-06-02: bounded disk cleanup, journal retention, backend-backup retention, and release-focused catalog drift classification were added. `.198` is active on backend hash `e285d421cef497beb6b4b929f36fb4296d6db1f4a4c786157b6751eec51619ca`; focused and broad post-cleanup lifecycle audits passed, and final raw Podman sweep was clean.
- Follow-up found Podman store commands can hang on `.198` beyond image prune (`podman system df`, image list/exists, and sometimes broad ps/inspect). The release cleanup path now skips Podman image/volume prune rather than touching that unstable path. `.198` is active on backend hash `c9695dc3db10ff6e593cdbcfbbdc94b2e98b6008aa62655bba51b9879b549e8c`; Uptime Kuma was repaired with a normal `package.restart`; focused and broad post-repair lifecycle audits passed, and final raw bad-state sweep was clean.
- On 2026-06-03, startup/adoption scanner hardening and pasta restart repair were deployed. `.198` is active on backend hash `2b72e83ff368e4a696ad701f8985b0a8e1e889d9f4844056dc063455df973b28`; `package.restart` for Uptime Kuma now returns successfully and restores the `3002` pasta listener; focused `meshtastic,jellyfin,filebrowser,uptime-kuma` and broad lifecycle audits passed.
- Later on 2026-06-03, expanded rollback cleanup and store-safe uninstall hardening were deployed. `.198` is active on backend hash `7f90345b75148b7ed748e1a417f31d1273e1646a9b742891858df11c5397051b`; `system.disk-cleanup` reclaimed `10.3 GB` from old backend and web UI rollback artifacts while still skipping Podman prune, and focused `meshtastic,jellyfin,filebrowser,uptime-kuma` lifecycle passed afterward.
- Latest 2026-06-03 follow-up deployed backend hash `d21202cd79794e3bfc882d37134afd7a41dac766bae386a675714e5fa030e94e`. It mitigates stale cached `container-list` state during Podman scan backoff, adds a bounded TCP reachability fallback for `container-health`, and adds Jellyfin `8096` to legacy pasta host-listener repair. Focused `meshtastic,jellyfin,filebrowser,uptime-kuma` lifecycle passed on this hash. Broad lifecycle still needs rerun on this latest hash.
- Current validation backend hash is `14d360a206d1e58f287c5722d709dace0284b0dea56b66aa4bce0f57c631631b`. It keeps the generic host-listener health direction, preserves the `container-health` fallback fix from `be95ea...`, hardens fresh local-build installs so `podman image exists <local-build-tag>` failures/timeouts rebuild instead of failing the lifecycle operation, and reduces duplicated legacy runtime port repair by deriving host ports from manifests. Targeted PhotoPrism and broad non-destructive `.198` lifecycle audits passed on this hash.
- Catalog metadata generation from manifests is now implemented via `scripts/generate-app-catalog.py`. The canonical catalog and UI public catalog are synced from manifest-owned fields, strict release drift is zero, and frontend build validation passed.
- Current live `.198` validation backend hash is `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de`. Broad non-destructive lifecycle is green on that deployed line after app health/port recovery, IndeedHub recovery, scoped legacy install hardening, and bounded Podman pull hardening.
- Local release validation now passes the full backend binary test target and every Rust workspace member after release cleanup fixes for scanner backoff wakeups, crash-recovery tests, manifest-port lookup, journal parsing, and boot-reconciler test determinism.
- Frontend release validation now passes `npm run type-check`, `npm test` (`548` tests), and `npm run build` after fixing mobile app-launch routing for new-tab apps and updating stale launch tests. Local `npm ci` is blocked by root-owned `neode-ui/node_modules` entries, so dependency reinstall remains a local environment cleanup item requiring explicit approval.
- Reboot validation is not yet green. User reported that a reboot test left IndeeHub stopped afterward, with multiple containers killed by SIGKILL during shutdown/reboot and at least one crash. Treat post-reboot recovery as the active release blocker.
- Local follow-up now hardens IndeeHub stack boot recovery and updates lifecycle validation so IndeeHub must still serve the Nostr signer bridge (`/nostr-provider.js`) before a launch probe passes.
## Completed In This Pass
- Pause checkpoint for resume: generated app-session metadata now covers manifest-owned launch ports, titles, and new-tab behavior. The next migration step should continue from proxy path/companion UI alias generation or return to the release blocker around post-reboot IndeeHub recovery.
- Updated `docs/APP-PACKAGING-MIGRATION-PLAN.md` to reflect the current `apps/<app-id>/manifest.yml` contract, replacing stale `archy-app.yml` next-step language with the actual parser/generator/orchestrator progress and the remaining migration blockers.
- Updated `docs/app-developer-guide.md` so developers see the current manifest fields, generated catalog flow, validation commands, and release lifecycle expectations instead of the older Nostr marketplace publish/trust-score draft.
- Verified the developer-guide manifest example parses as YAML, `scripts/generate-app-catalog.py` is idempotent, strict release catalog drift remains zero, and `git diff --check` is clean for the migration docs.
- Extended `scripts/generate-app-catalog.py` to also emit `neode-ui/src/views/appSession/generatedAppSessionConfig.ts` from manifests, and wired `appSessionConfig.ts` to merge generated launch ports/titles/new-tab launch behavior with the existing manual overrides for companion UIs and aliases.
- Added a Fedimint `interfaces.main` launch declaration for the Guardian wait/proxy UI on port `8175`, so that public launch surface is now represented in the manifest.
- Focused validation passed for the generated app-session path: Python helper compile, generator idempotence, strict catalog drift, `appSessionConfig.test.ts`, and frontend type-check.
- Aligned `docs/APP-PACKAGING-MIGRATION-PLAN.md` and `docs/app-developer-guide.md` with the current manifest/runtime contract so the release docs no longer describe the stale marketplace-style schema.
- Removed the hardcoded Portainer host-prep path and replaced it with a manifest plus generic Podman socket bind-mount preparation.
- Added generic Quadlet health drift detection for command, interval, timeout, and retry changes.
- Made rendered HTTP health helpers honor manifest timeouts.
- Added image availability guards before Quadlet starts/restarts so pruned images are pulled or built before systemd tries to start them.
- Fixed stale dependency handling so active manifest dependencies are not suppressed by old `user-stopped.json` entries.
- Added parent-app reconcile syncing for dependency Quadlet units.
- Validated Portainer, Filebrowser, BTCPay, and broad non-destructive audits on `.198`.
- Updated Meshtastic manifest to use a real available image, the real `/dev/ttyUSB0` device, the actual daemon data path, and a non-HTTP health check.
- Updated the lifecycle harness so non-HTTP apps do not require launch metadata.
- Added a generic manifest-owned file rendering primitive under `app.files` so apps can declare required bind-mounted config files without adding app-specific Rust/OS branches.
## Current `.198` State
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- Current validation backend hash: `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de`.
- `.198` root filesystem pressure is currently resolved for release validation: latest sweep showed `/` at 65% used with about 9.6G free after expanded rollback cleanup.
- Latest focused Fedimint, Immich, IndeedHub, and PhotoPrism audits passed on the current hash.
- Broad non-destructive lifecycle passed on the current hash before and after backend restart validation.
## Meshtastic Status
- Orchestrator routing is fixed and verified by the generated Quadlet unit.
- Current generated unit uses:
- `Image=docker.io/meshtastic/meshtasticd:daily-alpine`
- `Volume=/var/lib/archipelago/meshtastic:/var/lib/meshtasticd:Z`
- `AddDevice=/dev/ttyUSB0`
- `HealthCmd=test -f /var/lib/meshtasticd/config.yaml`
- The daemon starts and accepts TCP API connections on port `4403`.
- Full lifecycle passed on `.198`: install, stop, start, restart, uninstall with preserved data, and reinstall.
- A persisted `config.yaml` is required. The release path is now the generic `app.files` manifest primitive rather than a Meshtastic-specific backend hook, and this has been verified live on `.198` by deleting the file and proving `package.restart` recreates it from the manifest.
## Release Blockers
- Continue monitoring the current optimized release backend on `.198`; the previously observed release-binary segfault is not reproducing with hash `95dfd8530ae9621b2f16da05d2229fe40bed7e5f6e2097cf4c87000fe97b92de`.
- `system.disk-cleanup` now handles journal, backend-backup, legacy backend rollback, and web UI rollback retention while intentionally skipping Podman image/volume prune because Podman store commands can hang on `.198` under current load. Diagnose Podman store health separately from the release cleanup path.
- Release image probes have been further quarantined from the fragile Podman store commands and deployed to `.198` on backend hash `7e82532137292e91111f63819d1be7fa69f994ce20d6b5e0194915f194f20412`: runtime, legacy install, and companion image checks now use bounded targeted `podman image inspect` instead of `podman image exists` or `podman images -q`. Focused and broad non-destructive lifecycle validation passed on the deployed hash.
- Podman socket/runtime health remains a release blocker: `package.restart jellyfin` stopped the container but failed to complete because Podman reported `Cannot connect to Podman socket at /run/user/1000/podman/podman.sock: Permission denied`; `package.start jellyfin` recovered the app and the focused lifecycle passed afterward.
- Release-focused catalog drift now has zero missing catalog/manifest entries and zero metadata drift after generating catalog metadata from manifests.
- Backend-restart validation passed. Host-reboot validation is currently failed/pending due to post-reboot IndeeHub recovery. Reboot retests should run only after an explicit release checkpoint/approval.
- Local code-review/refactor cleanup gate has full local validation coverage now:
- `cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago` passed (`688` tests);
- all other workspace packages check/test clean;
- frontend type-check/tests/build passed;
- release build, catalog drift, catalog idempotence, Python helper compile, and whitespace checks passed.
- Before `1.8-alpha` release:
- deploy the post-reboot recovery fixes;
- prove focused IndeeHub lifecycle with Nostr signer injection intact;
- update the app packaging/developer docs so `docs/APP-PACKAGING-MIGRATION-PLAN.md` and `docs/app-developer-guide.md` match the current manifest/runtime contract and release-quality lifecycle expectations;
- complete the required refactor/remove-dead-code gate after correctness validation: remove obsolete transitional code, stale per-app hacks, duplicate lifecycle paths, and misleading compatibility fallbacks, then rerun release validation;
- require at least 3 consecutive clean post-fix reboots with broad non-destructive lifecycle green after each;
- prefer 5 consecutive clean reboots for production-release confidence;
- cut and smoke-test the `1.8-alpha` ISO.
## Bottom Line
We are working toward the intended goal: better than Umbrel/StartOS by making app behavior declarative and registry/manifest-owned. The migration is substantially advanced, Meshtastic manifest-owned config generation is verified live, catalog metadata is generated from manifests, disk cleanup/backup retention is in place without Podman prune risk, and full local backend/frontend workspace validation has been green. Remaining follow-up for `1.8-alpha` is post-reboot recovery validation, especially IndeeHub plus Nostr signer behavior, repeated reboot passes, ISO cut/smoke test, separate Podman socket/store-health diagnosis, and optional local cleanup of root-owned frontend dependencies before rerunning `npm ci`.

Some files were not shown because too many files have changed in this diff Show More