Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa1ca013f | ||
|
|
5af9a22b98 | ||
|
|
786498a57a | ||
|
|
790ad154f3 | ||
|
|
0c8991b519 | ||
|
|
e2c2f942c2 | ||
|
|
937ba7e115 | ||
|
|
e056c2477b | ||
|
|
7bd22f1f80 | ||
|
|
cfb0e4735a | ||
|
|
95f9a805b1 | ||
|
|
640dc87a5f | ||
|
|
327a4e34dd | ||
|
|
bf2793be7b | ||
|
|
1973d76427 | ||
|
|
403fa6eff3 | ||
|
|
3214d6aff3 | ||
|
|
459046b21c | ||
|
|
91adc281ca | ||
|
|
a9c4e54023 | ||
|
|
8c8e4d7a29 | ||
|
|
9d3347463a | ||
|
|
d462e44453 | ||
|
|
1af583e1ab | ||
|
|
2fac63e58c | ||
|
|
2999ab62ea | ||
|
|
5b052372b7 | ||
|
|
4232424b23 | ||
|
|
60fe761def | ||
|
|
9b9fa9cdee | ||
|
|
329e7811eb | ||
|
|
21aaacc8b4 | ||
|
|
ab85827187 | ||
|
|
bea745047d | ||
|
|
a483fe4baa | ||
|
|
0ed892a412 | ||
|
|
bb808df89a | ||
|
|
c800293f1f | ||
|
|
340b981b79 | ||
|
|
c49e8fcacd | ||
|
|
495b90782a | ||
|
|
0cfb4dc81c | ||
|
|
b8ac68d844 | ||
|
|
eaf13effd5 | ||
|
|
0339268c43 | ||
|
|
6fd1cf9ba7 | ||
|
|
8d4b309753 | ||
|
|
b11c6c17d1 | ||
|
|
e474a2b4c9 | ||
|
|
00c32688f8 | ||
|
|
d6f108d818 | ||
|
|
6a30ff11bd | ||
|
|
22df3f8f5f | ||
|
|
87853fc29c | ||
|
|
b7c2fd081f | ||
|
|
809b76526e | ||
|
|
760796f650 | ||
|
|
10e4f218a6 | ||
|
|
84b283f5b6 | ||
|
|
8f2e03df2a | ||
|
|
c79afa9541 | ||
|
|
f818f1dcc1 | ||
|
|
de60f7e21e | ||
|
|
881478a873 | ||
|
|
755ba5562d | ||
|
|
182f18ecf3 | ||
|
|
1a3d726eac | ||
|
|
c393b96da3 | ||
|
|
09ec64932f | ||
|
|
9079d404d6 | ||
|
|
af9d531a00 | ||
|
|
136eda16c9 | ||
|
|
626a89bdbc | ||
|
|
68784be4db | ||
|
|
853d51ae14 | ||
|
|
a578834462 | ||
|
|
c31c3765f4 | ||
|
|
bdd5a2c43e | ||
|
|
8eb03d106e | ||
|
|
4da6e3b43c | ||
|
|
7be7420c4f | ||
|
|
34c4e87d14 | ||
|
|
e61c757633 | ||
|
|
cc1f8fba72 | ||
|
|
556f2e7cac | ||
|
|
0898c54765 | ||
|
|
f4368785f0 | ||
|
|
608f4c17f0 | ||
|
|
92c58141af | ||
|
|
7b2f4cb05f | ||
|
|
e65e76cd9d | ||
|
|
6d03ed5a69 | ||
|
|
522c046525 | ||
|
|
56f956973e | ||
|
|
bd69ef41d5 | ||
|
|
eeb08fc78f | ||
|
|
1836b035b4 | ||
|
|
3e01e57c8d | ||
|
|
ca3e2ee0ca | ||
|
|
5859ef77e7 | ||
|
|
f0bd49d03d | ||
|
|
cede77f3bc | ||
|
|
dd8a6cd9d7 | ||
|
|
ab96c97cb9 | ||
|
|
881779005a | ||
|
|
20bc9f250c | ||
|
|
87be717f40 | ||
|
|
75d147b69f | ||
|
|
edaece8716 | ||
|
|
ab27fb97f8 | ||
|
|
d736364ad7 | ||
|
|
e9898ead76 | ||
|
|
b25d41c5c6 | ||
|
|
32902d3891 | ||
|
|
92c578d3d9 | ||
|
|
6240064acf | ||
|
|
19dbf60f03 | ||
|
|
b49d8f1f8a | ||
|
|
ec36ac7e2c | ||
|
|
7104ba0cbf | ||
|
|
d0b08d2790 | ||
|
|
76288f541e | ||
|
|
b701e125b4 |
@@ -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,20 @@ 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),
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -159,8 +173,29 @@ fun WebViewScreen(
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
// handler silently no-ops and "Open in new tab" dies.
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
@@ -220,13 +255,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 +272,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+257
@@ -1,5 +1,262 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.96-alpha (2026-06-15)
|
||||
|
||||
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
|
||||
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
|
||||
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
|
||||
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
|
||||
|
||||
## v1.7.95-alpha (2026-06-15)
|
||||
|
||||
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
|
||||
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
|
||||
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
|
||||
|
||||
## v1.7.94-alpha (2026-06-15)
|
||||
|
||||
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
|
||||
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
|
||||
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
|
||||
|
||||
## v1.7.93-alpha (2026-06-14)
|
||||
|
||||
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
|
||||
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
|
||||
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
|
||||
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
|
||||
|
||||
## v1.7.92-alpha (2026-06-14)
|
||||
|
||||
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
|
||||
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
|
||||
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
|
||||
|
||||
## v1.7.91-alpha (2026-06-14)
|
||||
|
||||
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
|
||||
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
|
||||
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
|
||||
- The Electrum server app is back on the home screen and can be launched again.
|
||||
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
|
||||
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
|
||||
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
|
||||
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
|
||||
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
|
||||
|
||||
## v1.7.89-alpha (2026-06-12)
|
||||
|
||||
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
|
||||
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
|
||||
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
|
||||
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
|
||||
- The Lightning wallet now recovers and unlocks itself properly after restarts.
|
||||
|
||||
## v1.7.88-alpha (2026-06-12)
|
||||
|
||||
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
|
||||
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
|
||||
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
|
||||
|
||||
## v1.7.87-alpha (2026-06-12)
|
||||
|
||||
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
|
||||
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
|
||||
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
|
||||
|
||||
## v1.7.86-alpha (2026-06-12)
|
||||
|
||||
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
|
||||
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
|
||||
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
|
||||
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
|
||||
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
|
||||
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
|
||||
|
||||
## 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.
|
||||
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
|
||||
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
|
||||
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
|
||||
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
|
||||
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
|
||||
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
|
||||
- Validation passed with catalog JSON checks, shell syntax 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.68-alpha (2026-05-19)
|
||||
|
||||
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
|
||||
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
|
||||
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
|
||||
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
|
||||
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
|
||||
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
|
||||
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
|
||||
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
|
||||
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
|
||||
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
|
||||
|
||||
## v1.7.67-alpha (2026-05-18)
|
||||
|
||||
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
|
||||
- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.
|
||||
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
|
||||
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
|
||||
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
|
||||
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
|
||||
|
||||
## v1.7.66-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
|
||||
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
|
||||
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
|
||||
|
||||
## v1.7.65-alpha (2026-05-18)
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+266
-59
@@ -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",
|
||||
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"version": "2.3.9",
|
||||
"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",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -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,10 +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": "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/dashboard:v2.38.0",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -287,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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -304,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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.13.7
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'}`);
|
||||
});
|
||||
|
||||
@@ -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,15 +48,30 @@ app:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=3072
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
# The ElectrumX dashboard tile is served by the host-networked companion UI
|
||||
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
|
||||
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
|
||||
# so the tile resolves a launch URL without relying on the hand-maintained
|
||||
# override in appSessionConfig.ts (which the generator can clobber). The
|
||||
# backend only validates this block — it does not proxy/health-check it.
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: ElectrumX server status and connection details
|
||||
type: ui
|
||||
port: 50002
|
||||
protocol: http
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10m
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
app:
|
||||
id: fips-ui
|
||||
name: FIPS Mesh
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
|
||||
inside a container with host networking, serves a static dashboard on
|
||||
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
|
||||
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
|
||||
restart, and stable-channel daemon updates) go through the existing
|
||||
fips.* RPC methods, authenticated by the browser's own archipelago
|
||||
session — there is no separate secret to manage.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/fips-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/fips-ui:local
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8336 directly on the host IP and
|
||||
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8336
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
+81
-46
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# OnlyOffice - uses official image
|
||||
FROM onlyoffice/documentserver:7.5.0
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -45,5 +45,5 @@ app:
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'}`);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.63-alpha"
|
||||
version = "1.7.95-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.63-alpha"
|
||||
version = "1.7.96-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -256,6 +256,45 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
|
||||
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
|
||||
/// host but use a different port, so the strict allowlist (`host_ip`, no
|
||||
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
|
||||
/// header ("blocked by CORS policy"). Reflect the Origin when its host
|
||||
/// matches the request's own `Host` header — i.e. the app lives on the same
|
||||
/// address the node is being reached by, which transparently covers the LAN
|
||||
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
|
||||
/// to enumerate them. Auth is still enforced by the session cookie; this
|
||||
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
|
||||
/// origin) when there is no match.
|
||||
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
|
||||
if let Some(origin) = self.validate_origin(headers) {
|
||||
return origin;
|
||||
}
|
||||
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
// host portion (no scheme, no port) of an `scheme://host[:port]` value
|
||||
let host_of = |s: &str| -> Option<String> {
|
||||
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let host = host_port
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(host_port);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
};
|
||||
let origin_host = host_of(origin);
|
||||
let req_host = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(host_of);
|
||||
match (origin_host, req_host) {
|
||||
(Some(o), Some(r)) if o == r => origin.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -265,9 +304,10 @@ impl ApiHandler {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
let preflight_origin = self.app_cors_origin(req.headers());
|
||||
if !preflight_origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Origin", &preflight_origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
@@ -448,7 +488,8 @@ impl ApiHandler {
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
@@ -465,8 +506,8 @@ impl ApiHandler {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -99,33 +99,61 @@ impl ApiHandler {
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The LND wallet UI is served on its own APP_PORTS origin and fetches
|
||||
// this cross-origin, so it needs the CORS headers echoed back.
|
||||
let cors = |builder: hyper::http::response::Builder| {
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
};
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
Err(e) => Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
|
||||
// sends no macaroon. Use the shared authenticated client instead — the
|
||||
// same one lnd.getinfo and the wallet RPCs use.
|
||||
let request = match rpc.lnd_client().await {
|
||||
Ok((client, macaroon_hex)) => client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match request {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod handler;
|
||||
mod rpc;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
pub use handler::ApiHandler;
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ impl RpcHandler {
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
@@ -79,7 +92,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 +102,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,969 @@
|
||||
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)]
|
||||
pub(crate) 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(
|
||||
¶ms,
|
||||
"enabled_for_peers",
|
||||
&mut state.settings.enabled_for_peers,
|
||||
);
|
||||
update_bool(
|
||||
¶ms,
|
||||
"allow_peer_requests",
|
||||
&mut state.settings.allow_peer_requests,
|
||||
);
|
||||
update_bool(¶ms, "allow_http", &mut state.settings.allow_http);
|
||||
update_bool(¶ms, "allow_https", &mut state.settings.allow_https);
|
||||
update_bool(¶ms, "allow_tor", &mut state.settings.allow_tor);
|
||||
|
||||
update_endpoint(¶ms, "http_endpoint", &mut state.settings.http_endpoint)?;
|
||||
update_endpoint(
|
||||
¶ms,
|
||||
"https_endpoint",
|
||||
&mut state.settings.https_endpoint,
|
||||
)?;
|
||||
update_endpoint(¶ms, "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()
|
||||
}
|
||||
|
||||
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
|
||||
/// the random password, its derived `rpcauth` line, and the client env file.
|
||||
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
|
||||
/// required `secret_env`, so this must run before bitcoind starts — otherwise
|
||||
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
|
||||
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
|
||||
pub(crate) 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -533,6 +533,19 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
// Respect operator removal: a peer the operator deleted must not
|
||||
// silently re-join via a stale invite. The tombstone is only cleared
|
||||
// by an explicit local action (manually adding the node or accepting
|
||||
// an incoming invite) — not by a remote-triggered join.
|
||||
if federation::load_removed_dids(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains(did)
|
||||
{
|
||||
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
|
||||
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
|
||||
@@ -115,10 +115,12 @@ impl RpcHandler {
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any
|
||||
// peers — almost always outbound UDP/8668 dropped by the
|
||||
// local firewall/router, or the anchor itself being down.
|
||||
"no_outbound_udp_or_anchor_down"
|
||||
// Daemon is up with a key but hasn't authenticated any peers —
|
||||
// almost always the outbound connection to the anchor being
|
||||
// dropped by the local firewall/router, or the anchor itself
|
||||
// being down. The public anchor is reached over TCP/8443 (not
|
||||
// UDP/8668 — that endpoint is dead).
|
||||
"no_outbound_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
@@ -126,8 +128,8 @@ impl RpcHandler {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"no_outbound_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -180,6 +181,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
|
||||
@@ -63,6 +63,7 @@ impl RpcHandler {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -9,24 +9,77 @@ use super::LND_REST_BASE_URL;
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
// valid enum value and returns 400 "parsing field type"; the native
|
||||
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// The .116 case: LND container is up but its REST endpoint isn't
|
||||
// reachable on the expected port (e.g. published-port drift), so
|
||||
// the connection is refused. This is NOT a locked wallet — emit a
|
||||
// distinct code so the UI stops mislabelling it.
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
|
||||
return Err(receive_error(
|
||||
RECEIVE_REST_UNREACHABLE,
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let status = resp.status();
|
||||
let raw_body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
.context("Bitcoin address response could not be read")?;
|
||||
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": raw_body,
|
||||
})
|
||||
});
|
||||
|
||||
if !status.is_success() {
|
||||
let message = lnd_error_message(&body);
|
||||
let code = classify_lnd_address_error(&message);
|
||||
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
.get("error")
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let code = classify_lnd_address_error(error);
|
||||
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -499,12 +552,20 @@ impl RpcHandler {
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
// Use the per-node secret as the LND wallet password (NOT the
|
||||
// caller-supplied one) so the unattended boot path can auto-unlock this
|
||||
// wallet. The wallet stays recoverable from the Archipelago seed via the
|
||||
// derived entropy above. This unifies both init paths on one password
|
||||
// source — the divergence here is what left wallets locked fleet-wide.
|
||||
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
|
||||
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -543,3 +604,143 @@ 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()
|
||||
}
|
||||
|
||||
// Stable, machine-readable reason codes for receive-address failures. They are
|
||||
// embedded in the error message as a `[CODE]` token so the frontend
|
||||
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
|
||||
// instead of guessing wallet state by substring-matching — which is what made
|
||||
// .228 report "wallet is locked" when LND's REST was merely unreachable.
|
||||
//
|
||||
// Every receive error string starts with "Bitcoin address" so it survives the
|
||||
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
|
||||
// flattened to the generic "Operation failed" message (the .116 symptom).
|
||||
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
|
||||
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
|
||||
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
|
||||
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
|
||||
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
|
||||
|
||||
/// Build a receive-address error carrying a reason code the UI can map.
|
||||
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
|
||||
}
|
||||
|
||||
/// A sensible default human message per code (used for non-UI callers and logs;
|
||||
/// the frontend renders its own copy from the code).
|
||||
fn default_receive_detail(code: &str) -> &'static str {
|
||||
match code {
|
||||
RECEIVE_REST_UNREACHABLE => {
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
|
||||
}
|
||||
RECEIVE_WALLET_LOCKED => {
|
||||
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
|
||||
}
|
||||
RECEIVE_WALLET_UNINITIALIZED => {
|
||||
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
|
||||
}
|
||||
RECEIVE_SYNCING => {
|
||||
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
|
||||
}
|
||||
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a non-2xx LND error body/message into a reason code. The wording of
|
||||
/// LND's REST errors is stable enough to bucket: a locked wallet, an
|
||||
/// uninitialized wallet, a syncing chain, or some other failure.
|
||||
fn classify_lnd_address_error(message: &str) -> &'static str {
|
||||
let m = message.to_lowercase();
|
||||
if m.contains("locked") || m.contains("unlock") {
|
||||
RECEIVE_WALLET_LOCKED
|
||||
} else if m.contains("synchroniz")
|
||||
|| m.contains("syncing")
|
||||
|| m.contains("not yet ready")
|
||||
|| m.contains("in the process of starting")
|
||||
{
|
||||
RECEIVE_SYNCING
|
||||
} else if m.contains("wallet not found")
|
||||
|| m.contains("not exist")
|
||||
|| m.contains("uninitialized")
|
||||
|| m.contains("not initialized")
|
||||
|| m.contains("create a wallet")
|
||||
|| m.contains("no wallet")
|
||||
{
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
} else {
|
||||
RECEIVE_LND_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_locked_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet locked, please unlock"),
|
||||
RECEIVE_WALLET_LOCKED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_uninitialized_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet not found, create a wallet first"),
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_syncing() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("server is still in the process of starting"),
|
||||
RECEIVE_SYNCING
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_is_generic_error() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("some other failure"),
|
||||
RECEIVE_LND_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
|
||||
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
|
||||
// and carry the [CODE] token the frontend parses.
|
||||
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
|
||||
let s = format!("{err}");
|
||||
assert!(s.starts_with("Bitcoin address"), "got: {s}");
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Failed to start",
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!(
|
||||
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
| "fips"
|
||||
| "fips-ui"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +219,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
|
||||
// VPN/mesh daemons need TUN + NET_ADMIN.
|
||||
// Note: --device=/dev/net/tun is added separately in install.rs
|
||||
"nostr-vpn" | "fips" => vec![
|
||||
"--cap-add=NET_ADMIN".to_string(),
|
||||
@@ -262,7 +264,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
|
||||
}
|
||||
"mempool-api" => (
|
||||
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
|
||||
@@ -312,11 +314,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"dwn" => (
|
||||
"curl -sf http://localhost:3000/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => return vec![],
|
||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||
@@ -360,10 +357,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX: large cache materially speeds initial history indexing.
|
||||
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
|
||||
// socket buffers, and reorg/indexing spikes.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "4g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
// conservative and give the process headroom to avoid restart loops.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "6g",
|
||||
"cryptpad" => "512m",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
@@ -384,11 +381,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" => "1g",
|
||||
"fips" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
@@ -494,6 +491,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(),
|
||||
@@ -582,6 +584,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)],
|
||||
}
|
||||
}
|
||||
@@ -782,11 +785,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(),
|
||||
@@ -959,6 +960,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,
|
||||
@@ -988,7 +990,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" => (
|
||||
@@ -1121,18 +1123,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 [
|
||||
|
||||
@@ -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!(
|
||||
@@ -646,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";
|
||||
@@ -742,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();
|
||||
@@ -760,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")?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,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
|
||||
};
|
||||
@@ -936,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 \
|
||||
@@ -1133,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
|
||||
@@ -1272,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
|
||||
);
|
||||
@@ -1853,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) {
|
||||
@@ -1872,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;
|
||||
@@ -1926,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,
|
||||
}
|
||||
}
|
||||
@@ -1984,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(());
|
||||
}
|
||||
|
||||
@@ -2010,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
|
||||
@@ -2042,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),
|
||||
@@ -2076,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;
|
||||
}
|
||||
|
||||
@@ -2098,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.
|
||||
@@ -2193,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%%=*}"
|
||||
@@ -2204,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
|
||||
"#;
|
||||
@@ -2230,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,
|
||||
}
|
||||
}
|
||||
@@ -2257,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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2294,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));
|
||||
@@ -2322,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");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
|
||||
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
|
||||
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
|
||||
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
|
||||
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -520,12 +527,31 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
let missing_lnd_proxy = !content.contains("location /proxy/lnd/");
|
||||
if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if missing_lnd_proxy {
|
||||
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
|
||||
// back to `/electrs-status` (since 2026-03-08) for even older configs.
|
||||
// Both appear once per archipelago server block, so the block is added
|
||||
// to every server block that proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry_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",
|
||||
@@ -281,13 +283,28 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "recommended",
|
||||
"grafana" => "recommended",
|
||||
"searxng" => "recommended",
|
||||
"tailscale" => "recommended",
|
||||
"tailscale" | "netbird" => "recommended",
|
||||
"portainer" => "recommended",
|
||||
// Optional: everything else
|
||||
_ => "optional",
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -479,6 +496,20 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"netbird" => AppMetadata {
|
||||
title: "NetBird".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(),
|
||||
@@ -486,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(),
|
||||
@@ -658,28 +682,62 @@ 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) {
|
||||
return Some(url);
|
||||
}
|
||||
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
|
||||
let Some(port) = launch_url_port(&url) 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the TCP port from a launch URL's authority.
|
||||
///
|
||||
/// The candidate URL can carry a path when it comes from a manifest
|
||||
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
|
||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||
fn launch_url_port(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -747,3 +805,26 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::Updating => "updating",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_url_port_tests {
|
||||
use super::launch_url_port;
|
||||
|
||||
#[test]
|
||||
fn parses_port_with_trailing_path() {
|
||||
// Regression: manifest interfaces.main yields a path-suffixed URL.
|
||||
// The old rsplit(':') parse produced "8096/" and dropped the URL.
|
||||
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
|
||||
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_authority_port() {
|
||||
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_url_without_port() {
|
||||
assert_eq!(launch_url_port("http://localhost/"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +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_DASHBOARD_IMAGE"),
|
||||
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
|
||||
|
||||
// Fedimint
|
||||
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||
@@ -184,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"),
|
||||
|
||||
@@ -210,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;
|
||||
@@ -299,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![],
|
||||
}
|
||||
}
|
||||
@@ -355,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#"
|
||||
|
||||
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
/// Per-node LND wallet password file (random, 0600). Replaces the old
|
||||
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
|
||||
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
|
||||
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
|
||||
|
||||
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
|
||||
/// unlock fallback so wallets created by those builds still open; new wallets
|
||||
/// never use it, and the login-path migration rotates away from it.
|
||||
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
@@ -79,15 +88,125 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -121,68 +240,227 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
/// Read the per-node wallet password from the secrets file, if present.
|
||||
/// Never generates one — absence means "fall back to legacy / not set yet".
|
||||
async fn read_wallet_password() -> Option<String> {
|
||||
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
|
||||
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
|
||||
(!pw.is_empty()).then_some(pw)
|
||||
}
|
||||
|
||||
/// Return the per-node wallet password, generating and persisting a fresh
|
||||
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
|
||||
/// wallet — calling it merely to unlock an existing wallet would record a
|
||||
/// password that doesn't match it.
|
||||
pub(crate) async fn ensure_wallet_password() -> Result<String> {
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
return Ok(pw);
|
||||
}
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut raw);
|
||||
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
fs::write(path, &pw)
|
||||
.await
|
||||
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
|
||||
/// per-node secret (current scheme) first, then the legacy constant so wallets
|
||||
/// created by older builds still open.
|
||||
async fn unlock_password_candidates() -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
v.push(pw);
|
||||
}
|
||||
v.push(LEGACY_WALLET_PASSWORD.to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
|
||||
/// password (no point retrying) vs keep waiting for LND to come up.
|
||||
enum UnlockAttempt {
|
||||
Unlocked,
|
||||
WrongPassword,
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
|
||||
/// (WrongPassword — try the next candidate, don't retry) from transient
|
||||
/// not-ready / connection errors (NotReady — worth retrying).
|
||||
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
|
||||
let body = serde_json::json!({
|
||||
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() || text.contains("already unlocked") {
|
||||
UnlockAttempt::Unlocked
|
||||
} else if text.contains("invalid passphrase") {
|
||||
UnlockAttempt::WrongPassword
|
||||
} else {
|
||||
UnlockAttempt::NotReady
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockAttempt::NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
|
||||
/// password was actively rejected (unrecoverable — caller should recreate);
|
||||
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
|
||||
async fn unlock_existing_wallet() -> Result<bool> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
let candidates = unlock_password_candidates().await;
|
||||
// Retry only while LND's unlocker isn't ready yet. If every candidate is
|
||||
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
|
||||
// with a clear message instead of hanging the boot path for 60s+ (the wallet
|
||||
// was created with a password this node doesn't have → migration/recovery).
|
||||
for _ in 0..60 {
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
UnlockAttempt::Unlocked => return Ok(true),
|
||||
UnlockAttempt::WrongPassword => {}
|
||||
UnlockAttempt::NotReady => all_rejected = false,
|
||||
}
|
||||
}
|
||||
if all_rejected {
|
||||
tracing::warn!(
|
||||
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
|
||||
with a password this node does not have",
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/state"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("state")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
|
||||
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
|
||||
/// rejected (wrong password — try the next candidate); Err = transport/other.
|
||||
async fn change_wallet_password(
|
||||
client: &reqwest::Client,
|
||||
current: &str,
|
||||
new: &str,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
|
||||
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling LND changepassword")?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else if text.contains("invalid passphrase") {
|
||||
Ok(false)
|
||||
} else {
|
||||
anyhow::bail!("LND changepassword returned {status}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
|
||||
/// login, when the onboarding password is available as a candidate. If the
|
||||
/// per-node secret already opens the wallet, just unlock. Otherwise try each
|
||||
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
|
||||
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
|
||||
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
|
||||
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only act on a wallet that is actually LOCKED.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
|
||||
// If the per-node secret already opens it, nothing to rotate — just unlock.
|
||||
if let Some(secret) = read_wallet_password().await {
|
||||
if matches!(
|
||||
try_unlock_once(&client, &secret).await,
|
||||
UnlockAttempt::Unlocked
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet's new password becomes the per-node secret (generate if absent).
|
||||
let new_secret = ensure_wallet_password().await?;
|
||||
|
||||
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for cand in candidates {
|
||||
if cand.is_empty() || *cand == new_secret {
|
||||
continue;
|
||||
}
|
||||
match change_wallet_password(&client, cand, &new_secret).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) => continue, // wrong current password — try next candidate
|
||||
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -204,6 +482,7 @@ struct InitWalletRequest {
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -223,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let wallet_password =
|
||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
@@ -237,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -310,6 +592,7 @@ async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -447,7 +730,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
fn legacy_wallet_password_is_valid_for_lncli() {
|
||||
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
|
||||
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_candidates_always_include_legacy_fallback() {
|
||||
// With no per-node secret on disk in the test env, candidates fall back
|
||||
// to the legacy constant so old wallets still open.
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -14,8 +14,8 @@ mod types;
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
remove_node, save_nodes, set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
|
||||
/// federation discovery can't silently re-add a peer they deleted.
|
||||
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedFile {
|
||||
pub(crate) removed: Vec<RemovedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedNode {
|
||||
pub(crate) did: String,
|
||||
pub(crate) removed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
@@ -114,6 +128,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
// Explicitly (re-)adding a node clears any prior tombstone so the
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@@ -127,9 +144,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Load the set of tombstoned (operator-removed) DIDs.
|
||||
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(std::collections::HashSet::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read removed nodes")?;
|
||||
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.removed.into_iter().map(|r| r.did).collect())
|
||||
}
|
||||
|
||||
/// Record a DID as removed. Idempotent.
|
||||
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let path = dir.join(REMOVED_FILE);
|
||||
let mut file: RemovedFile = if path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
RemovedFile::default()
|
||||
};
|
||||
if !file.removed.iter().any(|r| r.did == did) {
|
||||
file.removed.push(RemovedNode {
|
||||
did: did.to_string(),
|
||||
removed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear a DID's tombstone (operator explicitly re-added it).
|
||||
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file: RemovedFile =
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let before = file.removed.len();
|
||||
file.removed.retain(|r| r.did != did);
|
||||
if file.removed.len() != before {
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
@@ -287,6 +365,36 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_tombstones_and_readd_clears_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
// No tombstones yet.
|
||||
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
|
||||
|
||||
// Removing tombstones the DID so transitive discovery won't re-add it.
|
||||
remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
let removed = load_removed_dids(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
removed.contains("did:key:z1"),
|
||||
"removed DID must be tombstoned"
|
||||
);
|
||||
|
||||
// Explicitly re-adding clears the tombstone (intentional re-federate).
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!load_removed_dids(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("did:key:z1"),
|
||||
"explicit re-add must clear the tombstone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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,18 +111,30 @@ 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() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut nodes = super::storage::load_nodes(data_dir).await?;
|
||||
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
|
||||
// them via transitive discovery, or deleted (e.g. stale test) nodes
|
||||
// reappear on the next sync with any peer that still lists them.
|
||||
let removed = super::storage::load_removed_dids(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut added = 0u32;
|
||||
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;
|
||||
}
|
||||
// Skip anything the operator deliberately removed.
|
||||
if removed.contains(&hint.did) {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||
@@ -359,4 +373,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,38 @@ use tokio::process::Command;
|
||||
/// On-disk filename under `data_dir/`.
|
||||
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||
/// remove it from the UI once their own cluster has independent anchors.
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
|
||||
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
|
||||
/// Operators can remove it from the UI once their own cluster has
|
||||
/// independent anchors (removal persists, see `load`/`remove`).
|
||||
///
|
||||
/// IMPORTANT transport details, learned the hard way (see git history /
|
||||
/// the 2026-06-15 debugging on .116):
|
||||
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
|
||||
/// (host pings on both IP families but never completes a UDP FIPS
|
||||
/// handshake). `fips/config.rs` always knew this; the old default
|
||||
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
|
||||
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
|
||||
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
|
||||
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
|
||||
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
|
||||
/// An IPv4 literal sidesteps the resolver entirely.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
|
||||
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Public anchor (fips.v0l.io)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_ANCHORS_FILE)
|
||||
}
|
||||
|
||||
/// Load the seed-anchor list. Returns an empty list if the file
|
||||
/// doesn't exist yet — a first-boot node with no operator config.
|
||||
/// Load the seed-anchor list. A node that has never edited its anchor
|
||||
/// list (no file yet) gets the default public anchor so it can bootstrap
|
||||
/// the mesh out of the box. Once the operator edits anchors — including
|
||||
/// removing the default — a file exists and is authoritative, so removal
|
||||
/// persists and we never silently re-add it.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||
///
|
||||
/// Invoked through `sudo -n`: the upstream daemon's control socket
|
||||
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
|
||||
/// archipelago service user is not in the `fips` group, so a bare
|
||||
/// `fipsctl connect` fails with EACCES. This matches the privileged
|
||||
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
|
||||
/// Without it, seed anchors persist to disk but never actually dial,
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
"sudo fipsctl connect exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
@@ -185,10 +230,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
|
||||
/// path filter can restrict the exposed surface.
|
||||
pub const PEER_PORT: u16 = 5679;
|
||||
|
||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
|
||||
/// worth retrying over Tor, which reaches a different (less-filtered) route.
|
||||
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
|
||||
/// and are returned as-is so we neither mask real errors nor double latency.
|
||||
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -294,13 +305,22 @@ impl<'a> PeerRequest<'a> {
|
||||
let pref = self.preference().await;
|
||||
// FIPS-only or Auto: try FIPS first.
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_post_json(body).await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_post_json(body).await? {
|
||||
Some(resp) => {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
@@ -312,13 +332,19 @@ impl<'a> PeerRequest<'a> {
|
||||
use crate::settings::transport::TransportPref;
|
||||
let pref = self.preference().await;
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_get().await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
|
||||
@@ -1,156 +1,401 @@
|
||||
//! User-triggered FIPS upgrade from the upstream default branch.
|
||||
//! User-triggered FIPS upgrade from upstream GitHub releases.
|
||||
//!
|
||||
//! Flow (no auto-update, no background polling — user clicks a button):
|
||||
//! 1. Query GitHub for the upstream repo's default branch, then the
|
||||
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
||||
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
||||
//! 2. Compare with the installed daemon version reported by
|
||||
//! `fipsctl --version`. If identical, report "up to date".
|
||||
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
||||
//! 4. SHA256-verify the download.
|
||||
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
||||
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
|
||||
//! (`/releases/latest` returns the newest non-prerelease, non-draft
|
||||
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
|
||||
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
|
||||
//! reported by `fipsctl --version`. A dev/pre-release build of the
|
||||
//! same number (`0.3.0-dev`) counts as older than the released tag.
|
||||
//! 3. Pick the Debian package asset matching the host architecture
|
||||
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
|
||||
//! 4. Download both, SHA256-verify the .deb against the checksums file.
|
||||
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
|
||||
//!
|
||||
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
||||
//! publish stable release assets for per-commit builds. This module
|
||||
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
||||
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
||||
//! returns a clear "not yet available" error until the artefact source
|
||||
//! is decided.
|
||||
//! Upstream began publishing tagged releases with `.deb` artefacts and
|
||||
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
|
||||
//! the apply path is fully wired against those assets.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{service, UPSTREAM_REPO};
|
||||
|
||||
const GITHUB_API: &str = "https://api.github.com";
|
||||
const USER_AGENT: &str = "archipelago-fips-updater";
|
||||
|
||||
/// Result of `check_update()` — what the dashboard renders.
|
||||
/// Result of `check()` — what the dashboard renders.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheck {
|
||||
/// Currently installed daemon version (from `fipsctl --version`).
|
||||
pub current: Option<String>,
|
||||
/// Short SHA of the latest commit on upstream `main`.
|
||||
pub latest_commit: String,
|
||||
/// True when the installed version string does not mention the latest SHA.
|
||||
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
|
||||
pub latest_version: String,
|
||||
/// True when the installed version is older than `latest_version`.
|
||||
pub update_available: bool,
|
||||
/// Release channel this check tracked. Currently always "stable".
|
||||
pub channel: String,
|
||||
/// Browser download URL of the architecture-matched .deb for the
|
||||
/// latest release, when one exists (informational; apply() re-resolves).
|
||||
pub asset_url: Option<String>,
|
||||
/// Human-readable note for the UI.
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest commit on the upstream default branch and
|
||||
/// compare to the installed version. Never errors on "no package installed"
|
||||
/// — that is itself a valid state where an update is available.
|
||||
/// One GitHub release as we consume it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Build HTTP client")
|
||||
}
|
||||
|
||||
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
|
||||
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
|
||||
/// asset lookup simply finds nothing and surfaces a clear error.
|
||||
fn deb_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest stable release and compare to the installed
|
||||
/// version. Never errors on "no package installed" — that is itself a valid
|
||||
/// state where an update is available.
|
||||
pub async fn check() -> Result<UpdateCheck> {
|
||||
let current = service::daemon_version().await.ok();
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
let branch = fetch_default_branch(&client).await?;
|
||||
let latest = fetch_head_sha(&client, &branch).await?;
|
||||
let short = latest.chars().take(7).collect::<String>();
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
let update_available = match ¤t {
|
||||
Some(v) => !v.contains(&short),
|
||||
Some(v) => version_is_older(v, &release.tag_name),
|
||||
None => true,
|
||||
};
|
||||
|
||||
let asset_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.map(|a| a.browser_download_url.clone());
|
||||
|
||||
let notes = if update_available {
|
||||
format!(
|
||||
"Upstream {} is at {}; installed: {}",
|
||||
branch,
|
||||
short,
|
||||
"Update available: {} (installed: {})",
|
||||
release.tag_name,
|
||||
current.as_deref().unwrap_or("not installed")
|
||||
)
|
||||
} else {
|
||||
format!("Up to date ({} @ {})", branch, short)
|
||||
format!("Up to date ({})", release.tag_name)
|
||||
};
|
||||
|
||||
Ok(UpdateCheck {
|
||||
current,
|
||||
latest_commit: short,
|
||||
latest_version: release.tag_name,
|
||||
update_available,
|
||||
channel: "stable".to_string(),
|
||||
asset_url,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the update. Stubbed pending a stable artefact source for
|
||||
/// per-commit builds of the `fips` debian package. When this is wired
|
||||
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
/// Download, verify, and install the latest stable FIPS release, then
|
||||
/// restart the daemon. Steps: resolve release → match .deb for this arch
|
||||
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
pub async fn apply() -> Result<()> {
|
||||
anyhow::bail!(
|
||||
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
||||
per-commit .deb artefacts for main. Upgrade manually for now: \
|
||||
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
||||
)
|
||||
}
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
let deb = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no .deb for architecture {}",
|
||||
release.tag_name,
|
||||
deb_arch()
|
||||
)
|
||||
})?;
|
||||
let checksums = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "checksums-linux.txt")
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
|
||||
})?;
|
||||
|
||||
// Download the .deb (bytes) and the checksums (text).
|
||||
let deb_bytes = client
|
||||
.get(&deb.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub repo API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
||||
body.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
||||
}
|
||||
|
||||
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.context("download .deb")?
|
||||
.error_for_status()
|
||||
.context(".deb download HTTP error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("read .deb body")?;
|
||||
let checksums_text = client
|
||||
.get(&checksums.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub commits API")?;
|
||||
if !resp.status().is_success() {
|
||||
.context("download checksums")?
|
||||
.error_for_status()
|
||||
.context("checksums download HTTP error")?
|
||||
.text()
|
||||
.await
|
||||
.context("read checksums body")?;
|
||||
|
||||
// Verify SHA256 against the checksums manifest (sha256sum format:
|
||||
// "<hex>␠␠<filename>"). The filename column may include a leading
|
||||
// "*" (binary mode) or a path prefix, so match on the basename.
|
||||
let expected = checksums_text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let hash = parts.next()?;
|
||||
let name = parts.next()?.trim_start_matches('*');
|
||||
let base = name.rsplit('/').next().unwrap_or(name);
|
||||
(base == deb.name).then(|| hash.to_lowercase())
|
||||
})
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
|
||||
|
||||
let actual = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&deb_bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
if actual != expected {
|
||||
anyhow::bail!(
|
||||
"GitHub commits API returned {} for branch {}",
|
||||
resp.status(),
|
||||
branch
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
deb.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
||||
body.get("sha")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
||||
|
||||
// Stage the verified .deb in /tmp (shared with the host — the
|
||||
// service runs with PrivateTmp=no) and install it.
|
||||
let dest = std::env::temp_dir().join(&deb.name);
|
||||
tokio::fs::write(&dest, &deb_bytes)
|
||||
.await
|
||||
.with_context(|| format!("write {}", dest.display()))?;
|
||||
|
||||
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
|
||||
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
|
||||
// and `/var/lib/dpkg` are read-only *inside the service's mount
|
||||
// namespace* — and a `sudo` child inherits that namespace, so a
|
||||
// bare `sudo dpkg -i` fails with "Read-only file system" on the
|
||||
// dpkg database. `systemd-run` asks PID 1 to launch the command in
|
||||
// a fresh transient scope outside our sandbox, where the real
|
||||
// (writable) host filesystem is visible. `--wait` blocks until it
|
||||
// finishes and propagates the exit status; `--pipe` forwards
|
||||
// dpkg's output; `--collect` reaps the unit even on failure.
|
||||
//
|
||||
// dpkg flags, both load-bearing for this package specifically:
|
||||
// --force-confold: the fips package ships conffiles under
|
||||
// /etc/fips that archipelago rewrites at install time, so dpkg
|
||||
// hits an interactive "keep/replace?" conffile prompt. With our
|
||||
// closed stdin that aborts the configure step ("EOF on stdin at
|
||||
// conffile prompt") and leaves the package half-unpacked
|
||||
// (status `iU`), which `fips.status` then reports as
|
||||
// `installed:false`. confold = keep our managed config, no prompt.
|
||||
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
|
||||
// orders as NEWER than the stable tag `0.3.0` (a trailing
|
||||
// `-dev` sorts above the bare release). Moving a dev build onto
|
||||
// the stable line is therefore a dpkg "downgrade"; without this
|
||||
// flag dpkg warns and exits non-zero. Our own version_is_older()
|
||||
// gate already decided this is the wanted direction.
|
||||
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
|
||||
// other maintainer-script prompt.
|
||||
let dpkg = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"systemd-run",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--pipe",
|
||||
"--",
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg",
|
||||
"--force-confold",
|
||||
"--force-downgrade",
|
||||
"-i",
|
||||
])
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.await
|
||||
.context("sudo systemd-run dpkg -i failed to launch")?;
|
||||
// Best-effort cleanup regardless of dpkg result.
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
if !dpkg.status.success() {
|
||||
anyhow::bail!(
|
||||
"dpkg -i {} exited {}: {}",
|
||||
deb.name,
|
||||
dpkg.status,
|
||||
String::from_utf8_lossy(&dpkg.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Restart whichever fips unit is supervising the daemon so the new
|
||||
// binary takes over.
|
||||
let unit = service::active_unit().await;
|
||||
service::restart(unit)
|
||||
.await
|
||||
.with_context(|| format!("restart {} after install", unit))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/releases/latest` returns the most recent non-prerelease, non-draft
|
||||
/// release. We still re-check the flags defensively in case the endpoint
|
||||
/// or repo settings change.
|
||||
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
|
||||
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub releases/latest API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
|
||||
}
|
||||
let release: Release = resp.json().await.context("Parse release JSON")?;
|
||||
if release.draft || release.prerelease {
|
||||
anyhow::bail!(
|
||||
"releases/latest returned a {} release ({})",
|
||||
if release.draft { "draft" } else { "prerelease" },
|
||||
release.tag_name
|
||||
);
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
fn is_deb_for_arch(name: &str) -> bool {
|
||||
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
|
||||
}
|
||||
|
||||
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
|
||||
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
|
||||
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
|
||||
// Take the first whitespace token, drop a leading 'v'.
|
||||
let tok = s.split_whitespace().next().unwrap_or(s);
|
||||
let tok = tok.strip_prefix('v').unwrap_or(tok);
|
||||
// Split off any pre-release / build suffix.
|
||||
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
|
||||
Some(i) => (&tok[..i], &tok[i..]),
|
||||
None => (tok, ""),
|
||||
};
|
||||
let mut it = core.split('.');
|
||||
let major = it.next()?.parse::<u64>().ok()?;
|
||||
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let has_prerelease = rest.starts_with('-');
|
||||
Some(((major, minor, patch), has_prerelease))
|
||||
}
|
||||
|
||||
/// True when `installed` is strictly older than release tag `latest`.
|
||||
/// Same numeric triple but `installed` carries a pre-release suffix while
|
||||
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
|
||||
/// If either side can't be parsed, fall back to "differs ⇒ update".
|
||||
fn version_is_older(installed: &str, latest: &str) -> bool {
|
||||
match (parse_version(installed), parse_version(latest)) {
|
||||
(Some((ic, ipre)), Some((lc, lpre))) => {
|
||||
if ic != lc {
|
||||
ic < lc
|
||||
} else {
|
||||
// Equal cores: a pre-release is older than the final release.
|
||||
ipre && !lpre
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unparseable: be conservative — offer the update unless the
|
||||
// installed string already mentions the latest tag.
|
||||
!installed.contains(latest.trim_start_matches('v'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_returns_clear_stub_error() {
|
||||
let err = apply().await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("not yet wired"),
|
||||
"apply() should return an explicit not-yet-wired error, got: {}",
|
||||
err
|
||||
);
|
||||
#[test]
|
||||
fn test_deb_arch_maps_known() {
|
||||
// On the host running tests this is whatever the test arch is;
|
||||
// just assert it returns a non-empty, lowercase token.
|
||||
let a = deb_arch();
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, a.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_older() {
|
||||
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
|
||||
assert!(version_is_older("0.2.1", "v0.3.0"));
|
||||
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.4.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.1", "v0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
|
||||
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
|
||||
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
|
||||
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_deb_for_arch() {
|
||||
let arch = deb_arch();
|
||||
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
|
||||
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
|
||||
assert!(!is_deb_for_arch("checksums-linux.txt"));
|
||||
assert!(!is_deb_for_arch(&format!(
|
||||
"fips-0.3.0-linux-{}.tar.gz",
|
||||
arch
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_check_serialises() {
|
||||
let uc = UpdateCheck {
|
||||
current: Some("0.2.0-abc1234".to_string()),
|
||||
latest_commit: "def5678".to_string(),
|
||||
current: Some("0.3.0-dev".to_string()),
|
||||
latest_version: "v0.3.0".to_string(),
|
||||
update_available: true,
|
||||
channel: "stable".to_string(),
|
||||
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
|
||||
notes: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&uc).unwrap();
|
||||
assert!(json.contains("latest_commit"));
|
||||
assert!(json.contains("latest_version"));
|
||||
assert!(json.contains("update_available"));
|
||||
assert!(json.contains("stable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,38 +691,129 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
|
||||
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
/// Restart attempt at which we check for — and recover from — a corrupt
|
||||
/// ElectrumX database. Late enough that a transient restart won't trigger a
|
||||
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
|
||||
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
|
||||
|
||||
fn is_electrumx(name: &str) -> bool {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
|
||||
}
|
||||
|
||||
/// True when a container's logs show the specific corrupt-LevelDB signature.
|
||||
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
|
||||
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
|
||||
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
|
||||
/// so a normal restart never triggers the destructive resync below.
|
||||
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
|
||||
logs.contains("create_if_missing is false")
|
||||
|| logs.contains("Corruption:")
|
||||
|| (logs.contains("plyvel") && logs.contains("does not exist"))
|
||||
}
|
||||
|
||||
async fn electrumx_db_corrupt(name: &str) -> bool {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "60", name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(output)) => {
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
looks_like_corrupt_electrumx_db(&logs)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
|
||||
/// Files are owned by the container's mapped UID, so removal needs host sudo.
|
||||
/// The mount point itself is preserved (the container expects it to exist).
|
||||
/// Returns true if the reset succeeded.
|
||||
async fn reset_electrumx_data() -> bool {
|
||||
let rm = format!(
|
||||
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
|
||||
dir = ELECTRUMX_DATA_DIR,
|
||||
);
|
||||
matches!(
|
||||
crate::update::host_sudo(&["sh", "-c", &rm]).await,
|
||||
Ok(status) if status.success()
|
||||
)
|
||||
}
|
||||
|
||||
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
|
||||
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
|
||||
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
|
||||
/// corruption signature, and fired once per failure streak — when the resync
|
||||
/// stabilises the restart tracker clears, so a future corruption can heal too.
|
||||
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
|
||||
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
|
||||
return;
|
||||
}
|
||||
if !electrumx_db_corrupt(name).await {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
|
||||
name
|
||||
);
|
||||
if reset_electrumx_data().await {
|
||||
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
|
||||
} else {
|
||||
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
@@ -684,9 +826,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 +970,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 +1076,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
.unwrap_or(&90)
|
||||
);
|
||||
|
||||
let restarted = restart_container(&container.name).await;
|
||||
// Before restarting, self-heal a corrupt ElectrumX DB so
|
||||
// the restart resyncs cleanly instead of crash-looping.
|
||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
||||
|
||||
let restarted = restart_container(&container.name, &container.state).await;
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
let notification = Notification {
|
||||
@@ -1088,6 +1236,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 +1252,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 +1343,7 @@ mod tests {
|
||||
app_id: "indeedhub-postgres".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
@@ -1200,6 +1351,7 @@ mod tests {
|
||||
app_id: "indeedhub-redis".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
@@ -1207,6 +1359,7 @@ mod tests {
|
||||
app_id: "indeedhub-api".into(),
|
||||
state: "exited".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
},
|
||||
];
|
||||
@@ -1217,6 +1370,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 +1383,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 +1393,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 +1403,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 +1416,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
}];
|
||||
|
||||
@@ -1274,6 +1432,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
}];
|
||||
|
||||
@@ -1399,4 +1558,36 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_electrumx_matches_electrs_variants_only() {
|
||||
assert!(is_electrumx("electrumx"));
|
||||
assert!(is_electrumx("archy-electrumx"));
|
||||
assert!(is_electrumx("electrs"));
|
||||
assert!(is_electrumx("mempool-electrs"));
|
||||
assert!(!is_electrumx("bitcoin-knots"));
|
||||
assert!(!is_electrumx("lnd"));
|
||||
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_matches_plyvel_signature() {
|
||||
// The exact line ElectrumX exit-loops on (observed on .116).
|
||||
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
|
||||
assert!(looks_like_corrupt_electrumx_db(corrupt));
|
||||
assert!(looks_like_corrupt_electrumx_db(
|
||||
"Corruption: bad block in hist"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_ignores_healthy_logs() {
|
||||
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
|
||||
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
|
||||
assert!(!looks_like_corrupt_electrumx_db(healthy));
|
||||
// "catching up" / normal restart noise must not trigger a destructive wipe.
|
||||
assert!(!looks_like_corrupt_electrumx_db(
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ification.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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, // FIPS TCP fallback
|
||||
8336, // FIPS UI (fips-ui)
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
|
||||
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
|
||||
limits.insert("backup.upload-s3".to_string(), (3, 600));
|
||||
limits.insert("backup.download-s3".to_string(), (3, 600));
|
||||
// System operations
|
||||
limits.insert("update.apply".to_string(), (2, 600));
|
||||
// Update apply is an authenticated local admin action. Keep a guard
|
||||
// against accidental button storms without locking operators out for
|
||||
// ten minutes during OTA troubleshooting.
|
||||
limits.insert("update.apply".to_string(), (10, 60));
|
||||
limits.insert("system.reboot".to_string(), (2, 300));
|
||||
limits.insert("system.shutdown".to_string(), (2, 300));
|
||||
// Password and TOTP changes
|
||||
|
||||
+243
-17
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -731,6 +769,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
| "/archipelago/mesh-typed"
|
||||
| "/dwn"
|
||||
| "/transport/inbox"
|
||||
// Content *catalog* — the peer-browse entry point. This is the
|
||||
// exact path `/content` (no trailing slash); the prefix match
|
||||
// below only covers `/content/<id>` item fetches, so without
|
||||
// this the catalog 404s over the mesh and `content.browse-peer`
|
||||
// fails with "Peer returned error: 404 Not Found" (and never
|
||||
// falls back to Tor, since a 404 is a successful HTTP exchange).
|
||||
| "/content"
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
@@ -847,10 +892,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 +919,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 +938,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 +975,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 +996,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 +1064,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 +1105,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 +1177,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};
|
||||
@@ -1203,6 +1385,25 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
// slash). It must be reachable over the peer (FIPS) listener, else
|
||||
// `content.browse-peer` 404s over the mesh. Item fetches are
|
||||
// "/content/<id>".
|
||||
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
|
||||
assert!(
|
||||
is_peer_allowed_path("/content/abc123"),
|
||||
"items must be allowed"
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_transitional_state_on_merge() {
|
||||
// existing: user initiated a stop, spawn_transitional set Stopping.
|
||||
@@ -1211,10 +1412,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 +1434,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 +1444,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 +1482,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user