Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
837ba63466 | ||
|
|
8191d92bed | ||
|
|
ae8359da4b | ||
|
|
d91b858d9b | ||
|
|
19f2125a4d | ||
|
|
a992abcd06 | ||
|
|
4d6b4f76af | ||
|
|
0a94c0097f | ||
|
|
413d50116e | ||
|
|
daad50325b | ||
|
|
e05e356d64 | ||
|
|
cfb304a001 | ||
|
|
7804223152 | ||
|
|
a322b04021 | ||
|
|
645cf69ed7 | ||
|
|
01ec0565a6 | ||
|
|
30505f41ff |
@@ -85,7 +85,14 @@ scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.codex-target-*/
|
||||
.codex-tmp/
|
||||
.pnpm-store/
|
||||
**/__pycache__/
|
||||
*.bak
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||
# app/docs asset path with a descriptive filename.
|
||||
Screenshot *.png
|
||||
uploads/
|
||||
|
||||
@@ -132,6 +132,16 @@ fun WebViewScreen(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
fun openExternalUrl(url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -220,13 +230,7 @@ fun WebViewScreen(
|
||||
// Keep navigation within the Archipelago server
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Open external URLs in the system browser
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
openExternalUrl(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -243,18 +247,30 @@ fun WebViewScreen(
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: android.os.Message?,
|
||||
): Boolean {
|
||||
// Extract the URL from the hit test
|
||||
val data = view?.hitTestResult?.extra
|
||||
if (data != null) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(data),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport
|
||||
?: return false
|
||||
|
||||
val popup = WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
openExternalUrl(url)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
if (url != null) openExternalUrl(url)
|
||||
view?.stopLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
transport.webView = popup
|
||||
resultMsg.sendToTarget()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+254
-1
@@ -1,11 +1,264 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.56-alpha (2026-05-14)
|
||||
## 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.
|
||||
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
|
||||
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
|
||||
|
||||
## v1.7.62-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
|
||||
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
|
||||
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.61-alpha (2026-05-18)
|
||||
|
||||
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
|
||||
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
|
||||
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
|
||||
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
|
||||
|
||||
## v1.7.60-alpha (2026-05-18)
|
||||
|
||||
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
|
||||
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
|
||||
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
|
||||
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
|
||||
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
|
||||
|
||||
## v1.7.59-alpha (2026-05-17)
|
||||
|
||||
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
|
||||
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
|
||||
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
|
||||
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
|
||||
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
|
||||
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
|
||||
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
|
||||
|
||||
## v1.7.58-alpha (2026-05-17)
|
||||
|
||||
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||
|
||||
## v1.7.57-alpha (2026-05-17)
|
||||
|
||||
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||
- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.
|
||||
- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.
|
||||
- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.
|
||||
- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory.
|
||||
|
||||
## v1.7.56-alpha (2026-05-15)
|
||||
|
||||
- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.
|
||||
- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.
|
||||
- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.
|
||||
- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.
|
||||
- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.
|
||||
- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.
|
||||
- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.
|
||||
- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.
|
||||
- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||
- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy.
|
||||
- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest.
|
||||
- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism.
|
||||
- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads.
|
||||
- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies.
|
||||
- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows.
|
||||
- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI.
|
||||
- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages.
|
||||
- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing.
|
||||
|
||||
## v1.7.55-alpha (2026-05-13)
|
||||
|
||||
|
||||
+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,7 +48,7 @@ app:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=3072
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -5,9 +5,17 @@ app:
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
|
||||
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- |-
|
||||
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
|
||||
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
|
||||
sleep 30;
|
||||
done;
|
||||
exec fedimintd
|
||||
derived_env:
|
||||
- key: FM_P2P_URL
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
@@ -40,7 +48,9 @@ app:
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp
|
||||
- host: 8175
|
||||
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
|
||||
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
|
||||
@@ -52,7 +62,7 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://host.archipelago:8332
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
@@ -67,6 +77,15 @@ app:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Guardian UI
|
||||
description: Fedimint Guardian wait/proxy UI
|
||||
type: ui
|
||||
port: 8175
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
+81
-46
@@ -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.56-alpha"
|
||||
version = "1.7.85-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.56-alpha"
|
||||
version = "1.7.85-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -128,6 +128,22 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(handled) =
|
||||
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
|
||||
std::path::Path::new("/var/lib/archipelago"),
|
||||
from,
|
||||
incoming.from_name.as_deref(),
|
||||
&val,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let safe_from = sanitize_log_string(from);
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
self.auth_manager
|
||||
let outcome = self
|
||||
.auth_manager
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
@@ -88,7 +89,12 @@ impl RpcHandler {
|
||||
self.session_store.invalidate_all_except(token).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"session_rotated": true,
|
||||
"ssh_updated": outcome.ssh_updated,
|
||||
"ssh_error": outcome.ssh_error,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -0,0 +1,963 @@
|
||||
use super::RpcHandler;
|
||||
use crate::container::docker_packages;
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::{bitcoin_status, identity, peers};
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::ContainerState;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const RELAY_DIR: &str = "bitcoin-relay";
|
||||
const RELAY_STATE_FILE: &str = "state.json";
|
||||
const TXRELAY_USER: &str = "txrelay";
|
||||
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
|
||||
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
|
||||
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelayState {
|
||||
settings: BitcoinRelaySettings,
|
||||
requests: Vec<BitcoinRelayRequest>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelayState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: BitcoinRelaySettings::default(),
|
||||
requests: Vec::new(),
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelaySettings {
|
||||
enabled_for_peers: bool,
|
||||
allow_peer_requests: bool,
|
||||
allow_http: bool,
|
||||
allow_https: bool,
|
||||
allow_tor: bool,
|
||||
selected_peer_pubkey: Option<String>,
|
||||
http_endpoint: Option<String>,
|
||||
https_endpoint: Option<String>,
|
||||
tor_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled_for_peers: false,
|
||||
allow_peer_requests: false,
|
||||
allow_http: false,
|
||||
allow_https: true,
|
||||
allow_tor: false,
|
||||
selected_peer_pubkey: None,
|
||||
http_endpoint: None,
|
||||
https_endpoint: None,
|
||||
tor_endpoint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BitcoinRelayRequest {
|
||||
id: String,
|
||||
direction: RelayRequestDirection,
|
||||
status: RelayRequestStatus,
|
||||
peer_pubkey: String,
|
||||
peer_onion: String,
|
||||
peer_name: Option<String>,
|
||||
message: Option<String>,
|
||||
approved_endpoint: Option<String>,
|
||||
credential_secret_path: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestDirection {
|
||||
Incoming,
|
||||
Outbound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TrustedRelayPeer {
|
||||
pubkey: String,
|
||||
onion: String,
|
||||
name: Option<String>,
|
||||
relay_approved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TxRelayCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
|
||||
let local_node = local_sync_status().await;
|
||||
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
|
||||
|
||||
Ok(json!({
|
||||
"settings": state.settings,
|
||||
"trusted_nodes": trusted_nodes,
|
||||
"requests": state.requests,
|
||||
"local_node": local_node,
|
||||
"credentials": credential_status,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_update_settings(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
update_bool(
|
||||
¶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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,16 +305,87 @@ 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}");
|
||||
|
||||
// Delete prior profiles for this SSID/name first. Failed attempts can leave
|
||||
// a profile with key-mgmt but no saved PSK, causing future retries to fail
|
||||
// with "no secrets" before the supplied password is used.
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", ssid])
|
||||
.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(["device", "wifi", "connect", ssid, "password", password])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi profile create")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("WiFi profile create failed: {}", stderr);
|
||||
}
|
||||
|
||||
let activate = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", &conn_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi connect")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !activate.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&activate.stderr);
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
anyhow::bail!("WiFi connection failed: {}", stderr);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,40 @@ impl RpcHandler {
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let message = lnd_error_message(&body);
|
||||
anyhow::bail!(
|
||||
"LND could not generate a Bitcoin address ({}): {}",
|
||||
status,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
.get("error")
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -543,3 +563,35 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn lnd_error_message(body: &serde_json::Value) -> String {
|
||||
body.get("message")
|
||||
.or_else(|| body.get("error"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("unknown LND error")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::lnd_error_message;
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_prefers_message_field() {
|
||||
let body = serde_json::json!({
|
||||
"error": "grpc proxy error",
|
||||
"message": "wallet locked",
|
||||
});
|
||||
|
||||
assert_eq!(lnd_error_message(&body), "wallet locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_falls_back_to_unknown() {
|
||||
assert_eq!(
|
||||
lnd_error_message(&serde_json::json!({})),
|
||||
"unknown LND error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,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!(
|
||||
@@ -153,11 +153,13 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
],
|
||||
// Nginx Proxy Manager needs to bind low ports
|
||||
// Nginx Proxy Manager initializes/chowns mounted state on first boot.
|
||||
"nginx-proxy-manager" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
@@ -215,7 +217,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(),
|
||||
@@ -260,7 +262,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"),
|
||||
@@ -310,11 +312,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"dwn" => (
|
||||
"curl -sf http://localhost:3000/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => return vec![],
|
||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||
@@ -358,10 +355,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX: large cache materially speeds initial history indexing.
|
||||
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
|
||||
// socket buffers, and reorg/indexing spikes.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "4g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
// conservative and give the process headroom to avoid restart loops.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "6g",
|
||||
"cryptpad" => "512m",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
@@ -382,11 +379,11 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "256m",
|
||||
"filebrowser" => "256m",
|
||||
"searxng" => "512m",
|
||||
"dwn" => "256m",
|
||||
"portainer" => "256m",
|
||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||
"routstr" => "512m",
|
||||
"nostr-vpn" => "256m",
|
||||
"netbird" => "1g",
|
||||
"fips" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
@@ -492,6 +489,11 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"netbird" => vec![
|
||||
"netbird".into(),
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -580,6 +582,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
format!("{}/penpot-assets", base),
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
"netbird" => vec![format!("{}/netbird", base)],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
@@ -780,11 +783,9 @@ pub(super) async fn get_app_config(
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
// Sync-speed: bigger LRU/write cache during initial
|
||||
// history index. Default is 1200MB; the container gets
|
||||
// 4g (config.rs::get_memory_limit) so 3072 fits with
|
||||
// headroom.
|
||||
"CACHE_MB=3072".to_string(),
|
||||
// Keep cache below the container limit; high values
|
||||
// have caused OOM/restart loops during catch-up.
|
||||
"CACHE_MB=1024".to_string(),
|
||||
// Block-fetcher concurrency — defaults are conservative
|
||||
// for shared hosts; 4 is plenty for one bitcoind backend.
|
||||
"MAX_SEND=10000000".to_string(),
|
||||
@@ -924,25 +925,40 @@ pub(super) async fn get_app_config(
|
||||
]),
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
vec![
|
||||
"81:81".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"8444:443".to_string(),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nginx-proxy-manager" => {
|
||||
let admin_port = allocator
|
||||
.allocate_or_get(app_id, 8081, 81)
|
||||
.await
|
||||
.unwrap_or(8081);
|
||||
let http_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
||||
.await
|
||||
.unwrap_or(8084);
|
||||
let https_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||
.await
|
||||
.unwrap_or(8444);
|
||||
(
|
||||
vec![
|
||||
format!("{}:81", admin_port),
|
||||
format!("{}:80", http_port),
|
||||
format!("{}:443", https_port),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"portainer" => (
|
||||
vec!["9000:9000".to_string()],
|
||||
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,
|
||||
@@ -972,7 +988,7 @@ pub(super) async fn get_app_config(
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
@@ -1105,18 +1121,6 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
"dwn" => (
|
||||
vec!["3100:3000".to_string()],
|
||||
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
|
||||
vec![
|
||||
"DS_PORT=3000".to_string(),
|
||||
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
|
||||
"DS_DATA_STORE_URI=level://data/data".to_string(),
|
||||
"DS_EVENT_LOG_URI=level://data/events".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"botfights" => {
|
||||
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
|
||||
(
|
||||
|
||||
@@ -288,6 +288,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -389,6 +390,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netbird_start_order_starts_server_before_dashboard() {
|
||||
assert_eq!(
|
||||
startup_order("netbird"),
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
for package_id in [
|
||||
|
||||
@@ -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
|
||||
@@ -376,7 +409,8 @@ impl RpcHandler {
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
|
||||
{
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
@@ -402,7 +436,7 @@ impl RpcHandler {
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
@@ -440,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!(
|
||||
@@ -645,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";
|
||||
@@ -741,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();
|
||||
@@ -759,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")?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,7 +907,7 @@ impl RpcHandler {
|
||||
repair_nextcloud_permissions().await;
|
||||
}
|
||||
|
||||
ensure_host_port_listener(package_id, container_name).await?;
|
||||
ensure_host_port_listener(package_id, container_name, &ports).await?;
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL OK: {} (container: {})",
|
||||
@@ -915,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
|
||||
};
|
||||
@@ -935,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 \
|
||||
@@ -1132,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
|
||||
@@ -1271,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
|
||||
);
|
||||
@@ -1852,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) {
|
||||
@@ -1866,15 +1924,133 @@ async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
cleanup_stale_pasta_port("81").await;
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
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;
|
||||
@@ -1914,7 +2090,7 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
"nginx-proxy-manager"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("81").await;
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
true
|
||||
@@ -1925,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,
|
||||
}
|
||||
}
|
||||
@@ -1968,12 +2150,22 @@ async fn repair_nextcloud_permissions() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
|
||||
let Some(port) = required_host_port(package_id) else {
|
||||
async fn ensure_host_port_listener(
|
||||
package_id: &str,
|
||||
container_name: &str,
|
||||
runtime_ports: &[String],
|
||||
) -> Result<()> {
|
||||
let Some(port) = runtime_ports
|
||||
.first()
|
||||
.and_then(|p| p.split(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.or_else(|| published_host_port(container_name))
|
||||
.or_else(|| required_host_port(package_id))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_host_port(port, 10).await {
|
||||
if wait_for_host_port(package_id, port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1999,7 +2191,7 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
|
||||
));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2015,29 +2207,20 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
|
||||
))
|
||||
}
|
||||
|
||||
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(());
|
||||
fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = std::process::Command::new("podman")
|
||||
.args(["port", container_name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
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")
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.lines().find_map(|line| {
|
||||
line.rsplit(':')
|
||||
.next()
|
||||
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
@@ -2048,18 +2231,22 @@ fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
"nextcloud" => Some(8085),
|
||||
"nginx-proxy-manager" => Some(81),
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -2071,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.
|
||||
@@ -2166,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%%=*}"
|
||||
@@ -2177,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
|
||||
"#;
|
||||
@@ -2203,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,
|
||||
}
|
||||
}
|
||||
@@ -2230,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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2267,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));
|
||||
@@ -2295,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();
|
||||
|
||||
@@ -353,7 +353,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
"immich" => 2283,
|
||||
"photoprism" => 2342,
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 81,
|
||||
"nginx-proxy-manager" => 8081,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7778,
|
||||
_ => 0,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,14 +396,14 @@ async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
|
||||
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
||||
// Use /usr/sbin/usermod - not always in systemd's PATH
|
||||
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
||||
.args(["-p", &hash, &ssh_user])
|
||||
let status = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/sbin/usermod", "-p", &hash, &ssh_user])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
anyhow::bail!("usermod failed: {}", stderr);
|
||||
anyhow::bail!("sudo usermod failed: {}", stderr);
|
||||
}
|
||||
|
||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
let nginx_src = configs.join("nginx-archipelago.conf");
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -250,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("nginx config test failed after runtime sync: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@@ -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,9 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_minio-init_1",
|
||||
"indeedhub-build_relay_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"netbird-dashboard",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
// First pass: collect running UI containers. Custom UI-backed apps must
|
||||
@@ -116,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_") {
|
||||
@@ -123,6 +125,11 @@ impl DockerPackageScanner {
|
||||
continue;
|
||||
}
|
||||
|
||||
if app_id.starts_with("buildx_buildkit") {
|
||||
debug!("Skipping BuildKit helper container: {}", app_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip UI containers (they're merged with their parent apps)
|
||||
if app_id.ends_with("-ui") {
|
||||
debug!("Skipping UI container: {}", app_id);
|
||||
@@ -133,15 +140,22 @@ 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
|
||||
} else {
|
||||
// Prefer the known web UI port over arbitrary first binding
|
||||
// (for example Gitea exposes SSH on 2222 before web on 3001).
|
||||
let candidate = PodmanClient::lan_address_for(&app_id)
|
||||
.or_else(|| extract_lan_address(&container.ports));
|
||||
let candidate = if uses_allocated_launch_port(&app_id) {
|
||||
extract_lan_address(&container.ports)
|
||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||
} else {
|
||||
PodmanClient::lan_address_for(&app_id)
|
||||
.or_else(|| extract_lan_address(&container.ports))
|
||||
};
|
||||
reachable_lan_address(&app_id, candidate).await
|
||||
};
|
||||
|
||||
@@ -262,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",
|
||||
@@ -270,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 {
|
||||
@@ -468,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(),
|
||||
@@ -475,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(),
|
||||
@@ -546,10 +581,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
tier: "",
|
||||
},
|
||||
};
|
||||
apply_dynamic_metadata(app_id, &mut meta);
|
||||
meta.tier = get_app_tier(app_id);
|
||||
meta
|
||||
}
|
||||
|
||||
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let Ok(data) = std::fs::read_to_string(config_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = cfg
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 80)
|
||||
{
|
||||
meta.title = title.to_string();
|
||||
}
|
||||
if let Some(description) = cfg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 240)
|
||||
{
|
||||
meta.description = description.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
@@ -620,6 +682,18 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn netbird_configured_launch_url() -> Option<String> {
|
||||
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
|
||||
.await
|
||||
.ok()?;
|
||||
env.lines()
|
||||
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| PodmanClient::lan_address_for("netbird"))
|
||||
}
|
||||
|
||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||
let url = candidate?;
|
||||
if !requires_reachable_launch(app_id) {
|
||||
@@ -628,20 +702,25 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
|
||||
return None;
|
||||
};
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => Some(url),
|
||||
_ => {
|
||||
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
|
||||
None
|
||||
}
|
||||
if launch_port_reachable(port).await {
|
||||
Some(url)
|
||||
} else {
|
||||
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn launch_port_reachable(port: u16) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn requires_reachable_launch(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
@@ -673,6 +752,13 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_allocated_launch_port(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"filebrowser" | "nextcloud" | "nginx-proxy-manager" | "vaultwarden"
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
@@ -305,6 +305,7 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
@@ -66,7 +66,9 @@ pub async fn sync_with_peer(
|
||||
// hop. Only runs when the source is Trusted — Observer-level peers
|
||||
// don't get to expand our federation on their own authority.
|
||||
if peer.trust_level == TrustLevel::Trusted {
|
||||
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
|
||||
if let Err(e) =
|
||||
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
|
||||
{
|
||||
tracing::warn!(
|
||||
peer_did = %peer.did,
|
||||
error = %e,
|
||||
@@ -109,6 +111,7 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
|
||||
async fn merge_transitive_peers(
|
||||
data_dir: &std::path::Path,
|
||||
source_did: &str,
|
||||
local_did: &str,
|
||||
hints: &[FederationPeerHint],
|
||||
) -> Result<()> {
|
||||
if hints.is_empty() {
|
||||
@@ -119,8 +122,9 @@ async fn merge_transitive_peers(
|
||||
let mut refreshed = 0u32;
|
||||
|
||||
for hint in hints {
|
||||
// Don't import our own DID (a peer advertising us back).
|
||||
if hint.did == source_did {
|
||||
// Don't import the source peer advertising itself, or our own DID
|
||||
// when the source advertises us back as one of its trusted peers.
|
||||
if hint.did == source_did || hint.did == local_did {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||
@@ -359,4 +363,69 @@ mod tests {
|
||||
Some("npub1a")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_transitive_peers_skips_source_and_local_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
super::super::storage::save_nodes(
|
||||
dir.path(),
|
||||
&[FederatedNode {
|
||||
did: "did:key:zSource".into(),
|
||||
pubkey: "aa".into(),
|
||||
onion: "source.onion".into(),
|
||||
name: Some("Source".into()),
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "now".into(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
fips_npub: None,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
merge_transitive_peers(
|
||||
dir.path(),
|
||||
"did:key:zSource",
|
||||
"did:key:zLocal",
|
||||
&[
|
||||
FederationPeerHint {
|
||||
did: "did:key:zSource".into(),
|
||||
pubkey: "aa".into(),
|
||||
onion: "source.onion".into(),
|
||||
name: Some("Source".into()),
|
||||
fips_npub: None,
|
||||
},
|
||||
FederationPeerHint {
|
||||
did: "did:key:zLocal".into(),
|
||||
pubkey: "bb".into(),
|
||||
onion: "local.onion".into(),
|
||||
name: Some("Local".into()),
|
||||
fips_npub: None,
|
||||
},
|
||||
FederationPeerHint {
|
||||
did: "did:key:zPeer".into(),
|
||||
pubkey: "cc".into(),
|
||||
onion: "peer.onion".into(),
|
||||
name: Some("Kitchen".into()),
|
||||
fips_npub: Some("npub1peer".into()),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
|
||||
let peer = nodes
|
||||
.iter()
|
||||
.find(|n| n.did == "did:key:zPeer")
|
||||
.expect("trusted transitive peer should be added");
|
||||
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
|
||||
assert_eq!(peer.trust_level, TrustLevel::Trusted);
|
||||
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ struct ContainerHealth {
|
||||
app_id: String,
|
||||
state: String,
|
||||
podman_health: Option<String>,
|
||||
host_port_ready: Option<bool>,
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
@@ -427,42 +428,92 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
// nbxplorer, mempool-api) and UI containers need auto-restart too.
|
||||
// Only skip ephemeral containers (build infrastructure, init one-shots).
|
||||
|
||||
containers
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("Names").and_then(|v| {
|
||||
if let Some(arr) = v.as_array() {
|
||||
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
// Skip podman-compose infrastructure and one-shot init containers
|
||||
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
||||
return None;
|
||||
let mut out = Vec::new();
|
||||
for c in &containers {
|
||||
let name = c.get("Names").and_then(|v| {
|
||||
if let Some(arr) = v.as_array() {
|
||||
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
});
|
||||
let Some(name) = name else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
|
||||
// Skip podman-compose infrastructure and one-shot init containers
|
||||
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let state = c
|
||||
.get("State")
|
||||
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
|
||||
|
||||
let state = c
|
||||
.get("State")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_lowercase();
|
||||
|
||||
let podman_health = parse_podman_health(c, &state);
|
||||
let host_ports = host_tcp_ports_from_container(c);
|
||||
let host_port_ready = if host_ports.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host_ports_ready(&host_ports).await)
|
||||
};
|
||||
let healthy = state == "running"
|
||||
&& podman_health.as_deref() != Some("unhealthy")
|
||||
&& host_port_ready != Some(false);
|
||||
|
||||
out.push(ContainerHealth {
|
||||
name,
|
||||
app_id,
|
||||
state,
|
||||
podman_health,
|
||||
host_port_ready,
|
||||
healthy,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out: Vec<u16> = ports
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_lowercase();
|
||||
|
||||
let podman_health = parse_podman_health(c, &state);
|
||||
let healthy = state == "running" && podman_health.as_deref() != Some("unhealthy");
|
||||
|
||||
Some(ContainerHealth {
|
||||
name,
|
||||
app_id,
|
||||
state,
|
||||
podman_health,
|
||||
healthy,
|
||||
})
|
||||
.unwrap_or("tcp")
|
||||
.eq_ignore_ascii_case("tcp")
|
||||
})
|
||||
.collect()
|
||||
.filter_map(|p| {
|
||||
p.get("host_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|port| u16::try_from(port).ok())
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
async fn host_ports_ready(ports: &[u16]) -> bool {
|
||||
for port in ports {
|
||||
let ready = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
|
||||
)
|
||||
.await
|
||||
.is_ok_and(|r| r.is_ok());
|
||||
if !ready {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn live_container_ids(containers: &[serde_json::Value]) -> HashSet<String> {
|
||||
@@ -640,33 +691,41 @@ fn parse_health_from_status(status: &str) -> Option<String> {
|
||||
(start < end).then(|| status[start + 1..end].to_string())
|
||||
}
|
||||
|
||||
/// Try to restart a container.
|
||||
async fn restart_container(name: &str) -> bool {
|
||||
info!("Auto-restarting unhealthy container: {}", name);
|
||||
/// Try to recover a container. Running containers need a real restart so
|
||||
/// rootless network helpers such as pasta are recreated; `podman start` is a
|
||||
/// no-op for a running container with a missing host listener.
|
||||
async fn restart_container(name: &str, state: &str) -> bool {
|
||||
let action = if state == "running" {
|
||||
"restart"
|
||||
} else {
|
||||
"start"
|
||||
};
|
||||
info!("Auto-{}ing unhealthy container: {}", action, name);
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args([action, name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
info!("Successfully restarted container: {}", name);
|
||||
info!("Successfully recovered container: {}", name);
|
||||
true
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!("Failed to restart container {}: {}", name, stderr.trim());
|
||||
warn!("Failed to {} container {}: {}", action, name, stderr.trim());
|
||||
false
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("Failed to execute podman start for {}: {}", name, e);
|
||||
warn!("Failed to execute podman {} for {}: {}", action, name, e);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Timeout starting container {} (120s)", name);
|
||||
warn!("Timeout {}ing container {} (120s)", action, name);
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -684,9 +743,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
if crate::crash_recovery::is_recovery_complete() {
|
||||
break;
|
||||
}
|
||||
// Safety timeout: start anyway after 5 minutes even if recovery hangs
|
||||
if wait_start.elapsed().as_secs() > 300 {
|
||||
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
|
||||
// Safety timeout: start anyway after 30 minutes even if recovery hangs.
|
||||
// Stack recovery can take many minutes on low-resource nodes after reboot.
|
||||
if wait_start.elapsed().as_secs() > 1800 {
|
||||
warn!("Health monitor: boot recovery did not complete within 30 minutes, starting anyway");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
@@ -827,6 +887,7 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
}
|
||||
// Handle exited, stopped, created, and Podman-unhealthy running containers.
|
||||
if container.podman_health.as_deref() == Some("unhealthy")
|
||||
|| container.host_port_ready == Some(false)
|
||||
|| container.state == "exited"
|
||||
|| container.state == "stopped"
|
||||
|| container.state == "created"
|
||||
@@ -932,7 +993,7 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
.unwrap_or(&90)
|
||||
);
|
||||
|
||||
let restarted = restart_container(&container.name).await;
|
||||
let restarted = restart_container(&container.name, &container.state).await;
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
let notification = Notification {
|
||||
@@ -1088,6 +1149,7 @@ mod tests {
|
||||
app_id: "bitcoin-knots".to_string(),
|
||||
state: "running".to_string(),
|
||||
podman_health: Some("healthy".to_string()),
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
};
|
||||
assert!(health.healthy);
|
||||
@@ -1103,6 +1165,7 @@ mod tests {
|
||||
app_id: "mempool-web".to_string(),
|
||||
state: "exited".to_string(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
};
|
||||
assert!(!health.healthy);
|
||||
@@ -1193,6 +1256,7 @@ mod tests {
|
||||
app_id: "indeedhub-postgres".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
@@ -1200,6 +1264,7 @@ mod tests {
|
||||
app_id: "indeedhub-redis".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
@@ -1207,6 +1272,7 @@ mod tests {
|
||||
app_id: "indeedhub-api".into(),
|
||||
state: "exited".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
},
|
||||
];
|
||||
@@ -1217,6 +1283,7 @@ mod tests {
|
||||
app_id: "indeedhub-redis".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(!deps_are_running("indeedhub-api", &partial));
|
||||
@@ -1229,6 +1296,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("lnd", &core));
|
||||
@@ -1238,6 +1306,7 @@ mod tests {
|
||||
app_id: "bitcoin-knots".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("fedimint", &knots));
|
||||
@@ -1247,6 +1316,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
}];
|
||||
assert!(!deps_are_running("electrumx", &stopped));
|
||||
@@ -1259,6 +1329,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
}];
|
||||
|
||||
@@ -1274,6 +1345,7 @@ mod tests {
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: false,
|
||||
}];
|
||||
|
||||
|
||||
@@ -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,42 +1,140 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Scan all candidate serial ports and open the first Meshcore device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
fn device_type(&self) -> DeviceType {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
}
|
||||
}
|
||||
|
||||
fn advert_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), dev, info));
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No Meshcore device found on {} candidate ports: {:?}",
|
||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII marker for the original DM-via-channel format:
|
||||
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
|
||||
/// so the receiver has to guess the sender from its contact table — which
|
||||
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
/// with both the recipient and sender prefixes so attribution works on
|
||||
/// the receiver side.
|
||||
async fn send_dm_via_channel(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} handshake failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
}
|
||||
},
|
||||
match open_preferred_path(path).await {
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} open failed: {} — trying auto-detect",
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_type = device.device_type();
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
status.self_advert_name = device.advert_name();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
|
||||
/// Process a single outbound command from MeshService.
|
||||
async fn handle_send_command(
|
||||
cmd: MeshCommand,
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Async serial driver for Meshtastic devices.
|
||||
//!
|
||||
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
|
||||
//! module implements only the small subset Archipelago needs: connect,
|
||||
//! discover the local node, send/receive text packets, and provide synthetic
|
||||
//! contacts to the existing mesh listener.
|
||||
|
||||
use super::protocol::{InboundFrame, ParsedContact};
|
||||
use super::types::{DeviceInfo, DeviceType};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
const START1: u8 = 0x94;
|
||||
const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
const TO_RADIO_HEARTBEAT: u64 = 7;
|
||||
|
||||
const FROM_RADIO_PACKET: u64 = 2;
|
||||
const FROM_RADIO_MY_INFO: u64 = 3;
|
||||
const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
node_num: Option<u32>,
|
||||
user_id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
|
||||
}
|
||||
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_num: None,
|
||||
user_id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!(path = %self.device_path, "Starting Meshtastic handshake");
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
|
||||
let mut saw_meshtastic_frame = false;
|
||||
let mut saw_config_complete = false;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
remaining.min(Duration::from_millis(250)),
|
||||
self.read_from_radio(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(frame))) => {
|
||||
saw_meshtastic_frame = true;
|
||||
if matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
) {
|
||||
saw_config_complete = true;
|
||||
}
|
||||
self.handle_from_radio(&frame);
|
||||
if saw_config_complete && self.node_num.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) | Err(_) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_meshtastic_frame {
|
||||
anyhow::bail!("No Meshtastic serial API response");
|
||||
}
|
||||
|
||||
let node_id = self
|
||||
.node_num
|
||||
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
|
||||
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
|
||||
anyhow::bail!("Meshtastic serial API did not provide node identity");
|
||||
}
|
||||
let firmware_version = self
|
||||
.long_name
|
||||
.clone()
|
||||
.or_else(|| self.user_id.clone())
|
||||
.unwrap_or_else(|| "Meshtastic".to_string());
|
||||
|
||||
info!(node_id, name = %firmware_version, "Meshtastic identity");
|
||||
Ok(DeviceInfo {
|
||||
firmware_version,
|
||||
node_id,
|
||||
max_contacts: 200,
|
||||
device_type: DeviceType::Meshtastic,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.read_from_radio().await? {
|
||||
Some(frame) => {
|
||||
let config_complete = matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
);
|
||||
self.handle_from_radio(&frame);
|
||||
if config_complete || !self.contacts.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
let Some(frame) = self.read_from_radio().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
.or_else(|| self.short_name.clone())
|
||||
.or_else(|| self.user_id.clone())
|
||||
}
|
||||
|
||||
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
|
||||
if payload.len() > TO_RADIO_MAX {
|
||||
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
|
||||
.await
|
||||
.context("Meshtastic serial write timed out")?
|
||||
.context("Meshtastic serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
}
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
return None;
|
||||
};
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
self.node_num = Some(node_num);
|
||||
if let Some(user_id) = user_id {
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_NODE_INFO => {
|
||||
self.update_node_info(value);
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(
|
||||
field = other,
|
||||
len = value.len(),
|
||||
"Unhandled Meshtastic FromRadio field"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
.or(node.short_name)
|
||||
.or(node.id)
|
||||
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
|
||||
if Some(node.num) == self.node_num {
|
||||
self.long_name = Some(name.clone());
|
||||
}
|
||||
self.contacts.insert(
|
||||
node.num,
|
||||
ParsedContact {
|
||||
public_key_hex: hex::encode(key),
|
||||
advert_name: name,
|
||||
last_advert: node.last_heard.unwrap_or_default(),
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, portnum as u64, &mut decoded);
|
||||
encode_len_field(2, payload, &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut idx = 0;
|
||||
while idx < buf.len() {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let field = key >> 3;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (_, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
|
||||
return Some((field, &[]));
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let end = idx.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
field,
|
||||
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
|
||||
let mut idx = 0;
|
||||
let mut node_num = None;
|
||||
let mut user_id = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
|
||||
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
|
||||
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
node_num.map(|n| (n, user_id))
|
||||
}
|
||||
|
||||
struct ParsedNode {
|
||||
num: u32,
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
let mut idx = 0;
|
||||
let mut node = ParsedNode {
|
||||
num: 0,
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node.num = v as u32,
|
||||
(1, FieldValue::Fixed32(v)) => node.num = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
if let Some(user) = parse_user(b) {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if node.num == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
let mut idx = 0;
|
||||
let mut user = ParsedUser {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(user)
|
||||
}
|
||||
|
||||
struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decoded = decoded?;
|
||||
let mut didx = 0;
|
||||
let mut portnum = 0;
|
||||
let mut payload = Vec::new();
|
||||
while didx < decoded.len() {
|
||||
let (field, value, next) = next_field(decoded, didx)?;
|
||||
didx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => portnum = v as u32,
|
||||
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(ParsedPacket {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
let field = key >> 3;
|
||||
let mut pos = idx + n;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (v, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
Some((field, FieldValue::Varint(v), pos))
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
let end = pos.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
5 => {
|
||||
let end = pos.checked_add(4)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
let value =
|
||||
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
Some((field, FieldValue::Fixed32(value), end))
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = pos.checked_add(8)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
}
|
||||
wire => {
|
||||
warn!(wire, "Unsupported Meshtastic protobuf wire type");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut out = 0u64;
|
||||
for (i, b) in buf.iter().copied().enumerate().take(10) {
|
||||
out |= ((b & 0x7f) as u64) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Some((out, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint_field_into(field, value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 0, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 2, out);
|
||||
write_varint(bytes.len() as u64, out);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 5, out);
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push((value as u8 & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
fn string_field(bytes: &[u8]) -> Option<String> {
|
||||
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
out[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! Mesh networking: LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
|
||||
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards.
|
||||
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use super::protocol::{self, InboundFrame};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let by_id = Path::new("/dev/serial/by-id");
|
||||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||||
.iter()
|
||||
.any(|needle| file_name.contains(needle))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +28,6 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, // FIPS TCP fallback
|
||||
8444, 8084, // NPM
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
@@ -94,8 +93,12 @@ impl PortAllocator {
|
||||
.any(|m| m.host_port == port)
|
||||
}
|
||||
|
||||
fn can_bind(port: u16) -> bool {
|
||||
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()
|
||||
}
|
||||
|
||||
fn is_available(&self, port: u16) -> bool {
|
||||
!self.is_reserved(port) && !self.is_allocated(port)
|
||||
!self.is_reserved(port) && !self.is_allocated(port) && Self::can_bind(port)
|
||||
}
|
||||
|
||||
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
|
||||
|
||||
@@ -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
|
||||
|
||||
+242
-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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -840,6 +878,20 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
|
||||
/// scanner's authoritative view. Applies to all transitional variants.
|
||||
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Multi-container installs can legitimately spend several minutes before the
|
||||
/// primary user-facing container exists. BTCPay, for example, pulls/starts
|
||||
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
|
||||
/// card just because the primary container is absent during that setup window.
|
||||
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
|
||||
use crate::data_model::PackageState::*;
|
||||
match state {
|
||||
Installing | Starting | Restarting => INSTALLING_STUCK_TIMEOUT,
|
||||
_ => TRANSITIONAL_STUCK_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `state` is one of the transitional variants that a
|
||||
/// `spawn_transitional`-style background task owns. While such a state is
|
||||
/// set, the package scanner must not overwrite it with whatever podman
|
||||
@@ -860,6 +912,18 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn absent_transitional_replacement(
|
||||
state: &crate::data_model::PackageState,
|
||||
) -> Option<crate::data_model::PackageState> {
|
||||
match state {
|
||||
// A stop operation is complete once the container record disappears.
|
||||
// Do not leave the app card wedged in "Stopping..." just because the
|
||||
// background task died or the backend restarted before it wrote back.
|
||||
crate::data_model::PackageState::Stopping => Some(crate::data_model::PackageState::Stopped),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a fresh scan entry `fresh` into `existing` while preserving
|
||||
/// `existing.state` (which is transitional — the RPC spawn task owns it).
|
||||
/// Non-state observability fields are taken from `fresh` so the UI still
|
||||
@@ -867,8 +931,17 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
|
||||
fn merge_preserving_transitional(
|
||||
existing: &crate::data_model::PackageDataEntry,
|
||||
fresh: &crate::data_model::PackageDataEntry,
|
||||
user_stop_requested: bool,
|
||||
) -> crate::data_model::PackageDataEntry {
|
||||
let state = match (&existing.state, &fresh.state) {
|
||||
// A user-initiated stop must keep showing Stopping while podman still
|
||||
// reports Running. Repair/restart transitions do not have a user-stop
|
||||
// marker, so a fresh Running scan means the app recovered.
|
||||
(crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Running)
|
||||
if !user_stop_requested =>
|
||||
{
|
||||
fresh.state.clone()
|
||||
}
|
||||
// Removing with a live running container is stale: uninstall either
|
||||
// failed or Archipelago restarted before the spawned task could revert
|
||||
// state. Let the scanner recover the UI immediately instead of
|
||||
@@ -895,6 +968,11 @@ fn merge_preserving_transitional(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
|
||||
let msg = format!("{:#}", error);
|
||||
msg.contains("podman ps") && msg.contains("timed out")
|
||||
}
|
||||
|
||||
async fn scan_and_update_packages(
|
||||
scanner: &DockerPackageScanner,
|
||||
state: &StateManager,
|
||||
@@ -911,6 +989,7 @@ async fn scan_and_update_packages(
|
||||
pkg.exit_code = None;
|
||||
}
|
||||
}
|
||||
normalize_reachable_package_health(&mut packages).await;
|
||||
|
||||
let (current_data, _) = state.get_snapshot().await;
|
||||
let tor_addr = docker_packages::read_tor_address("archipelago").await;
|
||||
@@ -961,13 +1040,14 @@ async fn scan_and_update_packages(
|
||||
let overwrite = match existing {
|
||||
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
|
||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||
let stuck = now.duration_since(entered) > timeout;
|
||||
if stuck {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
|
||||
id,
|
||||
existing_entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
|
||||
timeout.as_secs(),
|
||||
pkg.state
|
||||
);
|
||||
transitional_since.remove(id);
|
||||
@@ -977,7 +1057,11 @@ async fn scan_and_update_packages(
|
||||
// observability fields (health, exit_code, lan_address
|
||||
// via installed) from the fresh scan so the UI still
|
||||
// sees live readings.
|
||||
let merged_entry = merge_preserving_transitional(existing_entry, pkg);
|
||||
let merged_entry = merge_preserving_transitional(
|
||||
existing_entry,
|
||||
pkg,
|
||||
user_stopped.contains(id),
|
||||
);
|
||||
if existing.cloned() != Some(merged_entry.clone()) {
|
||||
merged.insert(id.clone(), merged_entry);
|
||||
changed = true;
|
||||
@@ -1014,13 +1098,27 @@ 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);
|
||||
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
|
||||
let timeout = transitional_stuck_timeout(&entry.state);
|
||||
if now.duration_since(entered) > timeout {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
timeout.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
@@ -1072,6 +1170,99 @@ async fn scan_and_update_packages(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn normalize_reachable_package_health(
|
||||
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
) {
|
||||
for (id, pkg) in packages.iter_mut() {
|
||||
if pkg.state != crate::data_model::PackageState::Running {
|
||||
continue;
|
||||
}
|
||||
if !matches!(pkg.health.as_deref(), Some("starting" | "unhealthy" | "1")) {
|
||||
continue;
|
||||
}
|
||||
let Some(port) = pkg
|
||||
.installed
|
||||
.as_ref()
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref())
|
||||
.and_then(port_from_url)
|
||||
.or_else(|| fallback_package_port(id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if frontend_port_http_ready(port).await {
|
||||
debug!(app_id = %id, port, "normalizing reachable package health to healthy");
|
||||
pkg.health = Some("healthy".to_string());
|
||||
ensure_main_lan_address(pkg, port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn frontend_port_http_ready(port: u16) -> bool {
|
||||
let Ok(Ok(mut stream)) = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let request = b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
if stream.write_all(request).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
head.starts_with("HTTP/1.1 2")
|
||||
|| head.starts_with("HTTP/1.1 3")
|
||||
|| head.starts_with("HTTP/1.0 2")
|
||||
|| head.starts_with("HTTP/1.0 3")
|
||||
}
|
||||
|
||||
fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port: u16) {
|
||||
let Some(installed) = pkg.installed.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let main = installed
|
||||
.interface_addresses
|
||||
.entry("main".to_string())
|
||||
.or_insert_with(|| crate::data_model::InterfaceAddress {
|
||||
tor_address: String::new(),
|
||||
lan_address: None,
|
||||
});
|
||||
if main.lan_address.is_none() {
|
||||
main.lan_address = Some(format!("http://localhost:{port}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_package_port(app_id: &str) -> Option<u16> {
|
||||
match app_id {
|
||||
"fedimint" | "fedimintd" => Some(8175),
|
||||
"filebrowser" => Some(8083),
|
||||
"indeedhub" => Some(7778),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
"nostr-rs-relay" => Some(18081),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn port_from_url(url: &str) -> Option<u16> {
|
||||
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let port = host_port.rsplit_once(':')?.1;
|
||||
port.parse::<u16>().ok()
|
||||
}
|
||||
|
||||
/// Register Archipelago DWN protocols on startup.
|
||||
async fn register_dwn_protocols(data_dir: &std::path::Path) -> Result<()> {
|
||||
use crate::network::dwn_store::{DwnStore, ProtocolDefinition};
|
||||
@@ -1195,10 +1386,19 @@ mod merge_tests {
|
||||
// not clobber the transitional state owned by the RPC spawn task.
|
||||
let existing = make_entry(PackageState::Stopping, Some("healthy"));
|
||||
let fresh = make_entry(PackageState::Running, Some("starting"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, true);
|
||||
assert_eq!(merged.state, PackageState::Stopping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_user_stopping_recovers_when_container_is_running() {
|
||||
let existing = make_entry(PackageState::Stopping, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, false);
|
||||
assert_eq!(merged.state, PackageState::Running);
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_fresh_observability_fields() {
|
||||
// Non-state observability fields (health, exit_code, installed)
|
||||
@@ -1208,7 +1408,7 @@ mod merge_tests {
|
||||
existing.exit_code = None;
|
||||
let mut fresh = make_entry(PackageState::Running, Some("unhealthy"));
|
||||
fresh.exit_code = Some(0);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, true);
|
||||
assert_eq!(merged.state, PackageState::Stopping);
|
||||
assert_eq!(merged.health.as_deref(), Some("unhealthy"));
|
||||
assert_eq!(merged.exit_code, Some(0));
|
||||
@@ -1218,7 +1418,7 @@ mod merge_tests {
|
||||
fn stale_removing_recovers_when_container_is_running() {
|
||||
let existing = make_entry(PackageState::Removing, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, false);
|
||||
assert_eq!(merged.state, PackageState::Running);
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
@@ -1247,4 +1447,29 @@ mod merge_tests {
|
||||
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_uses_longer_stale_timeout_than_other_transitions() {
|
||||
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
|
||||
assert_eq!(
|
||||
transitional_stuck_timeout(&PackageState::Stopping),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Live download progress counters. Updated by download_component_resumable
|
||||
/// as bytes arrive and read by the update.status RPC so the UI can show
|
||||
@@ -502,6 +502,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
.context("Reading update state")?;
|
||||
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// Keep current_version in sync with the binary. Sideloaded nodes
|
||||
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
|
||||
// so without this the running 1.7.0-alpha binary would keep seeing
|
||||
@@ -517,11 +519,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
// if there's genuinely something newer.
|
||||
state.available_update = None;
|
||||
state.manifest_mirror = None;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// `update_in_progress` means a manifest OTA is downloaded and staged,
|
||||
// ready for apply. Older git/self-build update paths could leave this
|
||||
// flag stuck true without a staging directory, which traps the UI in an
|
||||
// unrecoverable state. Heal that on every state load.
|
||||
if state.update_in_progress && !has_staged_update(data_dir).await {
|
||||
warn!(
|
||||
staging = %data_dir.join("update-staging").display(),
|
||||
"Clearing stale update_in_progress without staged OTA files"
|
||||
);
|
||||
state.update_in_progress = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
save_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn has_staged_update(data_dir: &Path) -> bool {
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
|
||||
return false;
|
||||
};
|
||||
matches!(entries.next_entry().await, Ok(Some(_)))
|
||||
}
|
||||
|
||||
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
||||
let path = data_dir.join(UPDATE_STATE_FILE);
|
||||
let data = serde_json::to_string_pretty(state)?;
|
||||
@@ -1764,6 +1791,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_state_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"staged")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
current_version: "1.0.0".to_string(),
|
||||
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
||||
@@ -1800,6 +1832,22 @@ mod tests {
|
||||
assert!(loaded.available_update.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_state_clears_stale_in_progress_without_staging() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = UpdateState {
|
||||
update_in_progress: true,
|
||||
..UpdateState::default()
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
|
||||
assert!(!loaded.update_in_progress);
|
||||
let persisted = load_state(dir.path()).await.unwrap();
|
||||
assert!(!persisted.update_in_progress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_update_clears_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -8,9 +8,9 @@ pub mod runtime;
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
|
||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
|
||||
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
|
||||
SecretsProvider, SecurityPolicy, Volume,
|
||||
};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -42,6 +42,9 @@ pub struct AppDefinition {
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<Volume>,
|
||||
|
||||
#[serde(default)]
|
||||
pub files: Vec<GeneratedFile>,
|
||||
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
|
||||
@@ -51,6 +54,9 @@ pub struct AppDefinition {
|
||||
#[serde(default)]
|
||||
pub devices: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub interfaces: HashMap<String, AppInterface>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extensions: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
@@ -216,6 +222,8 @@ pub struct SecurityPolicy {
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub readonly_root: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub no_new_privileges: bool,
|
||||
#[serde(default = "default_network_policy")]
|
||||
pub network_policy: String,
|
||||
#[serde(default)]
|
||||
@@ -263,6 +271,14 @@ pub struct Volume {
|
||||
pub tmpfs_options: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GeneratedFile {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheck {
|
||||
#[serde(rename = "type")]
|
||||
@@ -277,6 +293,33 @@ pub struct HealthCheck {
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AppInterface {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "type", default = "default_interface_type")]
|
||||
pub interface_type: String,
|
||||
pub port: u16,
|
||||
#[serde(default = "default_http_protocol")]
|
||||
pub protocol: String,
|
||||
#[serde(default = "default_root_path")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
fn default_interface_type() -> String {
|
||||
"ui".to_string()
|
||||
}
|
||||
|
||||
fn default_http_protocol() -> String {
|
||||
"http".to_string()
|
||||
}
|
||||
|
||||
fn default_root_path() -> String {
|
||||
"/".to_string()
|
||||
}
|
||||
|
||||
fn default_interval() -> String {
|
||||
"30s".to_string()
|
||||
}
|
||||
@@ -302,8 +345,16 @@ impl AppManifest {
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if self.app.id.is_empty() {
|
||||
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
|
||||
if !is_valid_app_id(&self.app.id) {
|
||||
return Err(ManifestError::Invalid(
|
||||
"app.id must be lowercase ASCII letters, digits, or single hyphens".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.app.name.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"app.name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Exactly one of container.image or container.build must be set. We can't
|
||||
@@ -355,6 +406,11 @@ impl AppManifest {
|
||||
"container.network cannot be empty (omit the field to use default)".to_string(),
|
||||
));
|
||||
}
|
||||
if is_dangerous_network_mode(n) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.network '{n}' is not allowed in app manifests"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// custom_args: no empty strings (would inject literal "" into
|
||||
@@ -447,6 +503,12 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
validate_security(&self.app.security)?;
|
||||
validate_ports(&self.app.ports)?;
|
||||
validate_interfaces(&self.app.interfaces)?;
|
||||
validate_environment(&self.app.environment)?;
|
||||
validate_devices(&self.app.devices)?;
|
||||
|
||||
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
||||
for (i, v) in self.app.volumes.iter().enumerate() {
|
||||
if v.volume_type == "tmpfs" {
|
||||
@@ -466,6 +528,11 @@ impl AppManifest {
|
||||
v.volume_type
|
||||
)));
|
||||
} else {
|
||||
if v.volume_type != "bind" && v.volume_type != "volume" {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}].type must be bind, volume, or tmpfs"
|
||||
)));
|
||||
}
|
||||
if v.source.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}] ({}) must set source",
|
||||
@@ -478,6 +545,45 @@ impl AppManifest {
|
||||
v.volume_type
|
||||
)));
|
||||
}
|
||||
if v.volume_type == "bind" {
|
||||
validate_bind_source(i, &v.source)?;
|
||||
} else if !is_valid_named_volume(&v.source) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}].source must be a safe named volume"
|
||||
)));
|
||||
}
|
||||
validate_container_path(i, &v.target)?;
|
||||
validate_volume_options(i, &v.options)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (i, f) in self.app.files.iter().enumerate() {
|
||||
if f.path.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path cannot be empty"
|
||||
)));
|
||||
}
|
||||
if !std::path::Path::new(&f.path).is_absolute() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path must be absolute"
|
||||
)));
|
||||
}
|
||||
if f.content.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].content cannot be empty"
|
||||
)));
|
||||
}
|
||||
let file_path = std::path::Path::new(&f.path);
|
||||
let under_bind_mount = self
|
||||
.app
|
||||
.volumes
|
||||
.iter()
|
||||
.filter(|v| v.volume_type != "tmpfs" && !v.source.is_empty())
|
||||
.any(|v| file_path.starts_with(std::path::Path::new(&v.source)));
|
||||
if !under_bind_mount {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path must live under a bind-mounted volume source"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +591,255 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
if id.is_empty() || id.starts_with('-') || id.ends_with('-') || id.contains("--") {
|
||||
return false;
|
||||
}
|
||||
id.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
fn is_dangerous_network_mode(mode: &str) -> bool {
|
||||
mode.starts_with("container:") || mode.starts_with("ns:")
|
||||
}
|
||||
|
||||
fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
|
||||
let allowed_network_policies = ["isolated", "bridge", "host"];
|
||||
if !policy.network_policy.is_empty()
|
||||
&& !allowed_network_policies.contains(&policy.network_policy.as_str())
|
||||
{
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.network_policy must be one of {}",
|
||||
allowed_network_policies.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
let allowed_caps = [
|
||||
"CHOWN",
|
||||
"DAC_OVERRIDE",
|
||||
"FOWNER",
|
||||
"NET_ADMIN",
|
||||
"NET_BIND_SERVICE",
|
||||
"NET_RAW",
|
||||
"SETGID",
|
||||
"SETUID",
|
||||
"SYS_ADMIN",
|
||||
];
|
||||
let mut seen = HashSet::new();
|
||||
for cap in &policy.capabilities {
|
||||
if !allowed_caps.contains(&cap.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.capabilities contains unsupported capability '{cap}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(cap.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.capabilities contains duplicate capability '{cap}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
let mut seen_host = HashSet::new();
|
||||
for (i, port) in ports.iter().enumerate() {
|
||||
if port.host == 0 || port.container == 0 {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].host and ports[{i}].container must be non-zero"
|
||||
)));
|
||||
}
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].protocol must be tcp or udp"
|
||||
)));
|
||||
}
|
||||
if !seen_host.insert((port.host, protocol.to_string())) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports contains duplicate host binding {}/{}",
|
||||
port.host, protocol
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_interfaces(interfaces: &HashMap<String, AppInterface>) -> Result<(), ManifestError> {
|
||||
let allowed_types = ["ui", "api", "metrics"];
|
||||
let allowed_protocols = ["http", "https"];
|
||||
for (key, interface) in interfaces {
|
||||
if !is_valid_interface_key(key) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces key '{key}' must be lowercase ASCII letters, digits, hyphens, or underscores"
|
||||
)));
|
||||
}
|
||||
if interface.port == 0 {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.port must be non-zero"
|
||||
)));
|
||||
}
|
||||
if !allowed_types.contains(&interface.interface_type.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.type must be one of {}",
|
||||
allowed_types.join(", ")
|
||||
)));
|
||||
}
|
||||
if !allowed_protocols.contains(&interface.protocol.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.protocol must be one of {}",
|
||||
allowed_protocols.join(", ")
|
||||
)));
|
||||
}
|
||||
if !interface.path.starts_with('/') || interface.path.chars().any(char::is_control) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.path must start with '/' and contain no control characters"
|
||||
)));
|
||||
}
|
||||
if interface
|
||||
.name
|
||||
.as_ref()
|
||||
.is_some_and(|name| name.trim().is_empty())
|
||||
{
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.name cannot be empty when set"
|
||||
)));
|
||||
}
|
||||
if interface
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|description| description.trim().is_empty())
|
||||
{
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"interfaces.{key}.description cannot be empty when set"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_valid_interface_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
|
||||
let mut seen = HashSet::new();
|
||||
for (i, entry) in env.iter().enumerate() {
|
||||
let Some((key, _)) = entry.split_once('=') else {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment[{i}] must be KEY=VALUE"
|
||||
)));
|
||||
};
|
||||
if !is_valid_env_key(key) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment[{i}] has invalid key '{key}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(key) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment contains duplicate key '{key}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
|
||||
let mut seen = HashSet::new();
|
||||
for (i, device) in devices.iter().enumerate() {
|
||||
if !device.starts_with("/dev/") || device.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"devices[{i}] must be an absolute /dev path"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(device.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"devices contains duplicate entry '{device}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
||||
let path = std::path::Path::new(source);
|
||||
if !path.is_absolute() {
|
||||
if is_valid_named_volume(source) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must be absolute for host bind mounts or a safe named volume"
|
||||
)));
|
||||
}
|
||||
if source.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must not contain '..'"
|
||||
)));
|
||||
}
|
||||
if source.starts_with("/var/lib/archipelago/") || is_reviewed_host_bind_exception(source) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must be under /var/lib/archipelago or a reviewed host-bind exception"
|
||||
)))
|
||||
}
|
||||
|
||||
fn is_reviewed_host_bind_exception(source: &str) -> bool {
|
||||
source == "/run/user/1000/podman/podman.sock" || source == "/var/run/dbus"
|
||||
}
|
||||
|
||||
fn is_valid_named_volume(source: &str) -> bool {
|
||||
if source.is_empty() || source.contains('/') || source.contains("..") {
|
||||
return false;
|
||||
}
|
||||
source
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
fn validate_container_path(index: usize, target: &str) -> Result<(), ManifestError> {
|
||||
if !std::path::Path::new(target).is_absolute() || target.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].target must be an absolute container path without '..'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_volume_options(index: usize, options: &[String]) -> Result<(), ManifestError> {
|
||||
let allowed = ["rw", "ro", "z", "Z", "shared", "rshared", "slave", "rslave"];
|
||||
let mut seen = HashSet::new();
|
||||
for option in options {
|
||||
if !allowed.contains(&option.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].options contains unsupported option '{option}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(option.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].options contains duplicate option '{option}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host facts available to `derived_env` templates at apply time.
|
||||
///
|
||||
/// Mirrors the values `scripts/container-specs.sh:detect_environment()`
|
||||
@@ -657,6 +1012,92 @@ app:
|
||||
assert_eq!(manifest.app.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_interfaces_parse_with_defaults() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:1.0.0
|
||||
interfaces:
|
||||
main:
|
||||
port: 8080
|
||||
"#;
|
||||
|
||||
let manifest = AppManifest::parse(yaml).unwrap();
|
||||
let main = manifest.app.interfaces.get("main").unwrap();
|
||||
assert_eq!(main.interface_type, "ui");
|
||||
assert_eq!(main.port, 8080);
|
||||
assert_eq!(main.protocol, "http");
|
||||
assert_eq!(main.path, "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_interfaces_are_rejected() {
|
||||
let cases = [
|
||||
(
|
||||
"bad key",
|
||||
r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:1.0.0
|
||||
interfaces:
|
||||
Bad Key:
|
||||
port: 8080
|
||||
"#,
|
||||
"interfaces key",
|
||||
),
|
||||
(
|
||||
"bad protocol",
|
||||
r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:1.0.0
|
||||
interfaces:
|
||||
main:
|
||||
port: 8080
|
||||
protocol: ftp
|
||||
"#,
|
||||
"interfaces.main.protocol",
|
||||
),
|
||||
(
|
||||
"bad path",
|
||||
r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:1.0.0
|
||||
interfaces:
|
||||
main:
|
||||
port: 8080
|
||||
path: dashboard
|
||||
"#,
|
||||
"interfaces.main.path",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, yaml, expected) in cases {
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let ManifestError::Invalid(msg) = err else {
|
||||
panic!("{name}: expected invalid manifest, got {err:?}");
|
||||
};
|
||||
assert!(
|
||||
msg.contains(expected),
|
||||
"{name}: expected error containing {expected:?}, got {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_validation() {
|
||||
let yaml = r#"
|
||||
@@ -864,6 +1305,38 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_files_must_live_under_bind_mounts() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/test-app
|
||||
target: /data
|
||||
files:
|
||||
- path: /var/lib/archipelago/test-app/config.yaml
|
||||
content: |
|
||||
key: value
|
||||
"#;
|
||||
let manifest = AppManifest::parse(yaml).unwrap();
|
||||
assert_eq!(manifest.app.files.len(), 1);
|
||||
|
||||
let bad = yaml.replace(
|
||||
"/var/lib/archipelago/test-app/config.yaml",
|
||||
"/etc/test-app/config.yaml",
|
||||
);
|
||||
let err = AppManifest::parse(&bad).unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("bind-mounted volume source"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_custom_arg_is_rejected() {
|
||||
let yaml = r#"
|
||||
@@ -1089,6 +1562,157 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_manifest_values_are_rejected() {
|
||||
let cases = [
|
||||
(
|
||||
"bad app id",
|
||||
r#"
|
||||
app:
|
||||
id: Bad_App
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#,
|
||||
"app.id",
|
||||
),
|
||||
(
|
||||
"unsupported capability",
|
||||
r#"
|
||||
app:
|
||||
id: bad-cap
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
security:
|
||||
capabilities: [SYS_MODULE]
|
||||
"#,
|
||||
"unsupported capability",
|
||||
),
|
||||
(
|
||||
"docker socket bind",
|
||||
r#"
|
||||
app:
|
||||
id: bad-bind
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/run/docker.sock
|
||||
target: /var/run/docker.sock
|
||||
"#,
|
||||
"reviewed host-bind exception",
|
||||
),
|
||||
(
|
||||
"path-like relative bind source",
|
||||
r#"
|
||||
app:
|
||||
id: bad-bind
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: data/cache
|
||||
target: /data
|
||||
"#,
|
||||
"absolute for host bind mounts",
|
||||
),
|
||||
(
|
||||
"bad environment key",
|
||||
r#"
|
||||
app:
|
||||
id: bad-env
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
environment:
|
||||
- 1BAD=value
|
||||
"#,
|
||||
"invalid key",
|
||||
),
|
||||
(
|
||||
"duplicate host port",
|
||||
r#"
|
||||
app:
|
||||
id: bad-port
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
ports:
|
||||
- { host: 8080, container: 80, protocol: tcp }
|
||||
- { host: 8080, container: 81, protocol: tcp }
|
||||
"#,
|
||||
"duplicate host binding",
|
||||
),
|
||||
(
|
||||
"bad device",
|
||||
r#"
|
||||
app:
|
||||
id: bad-device
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
devices:
|
||||
- /tmp/fake-device
|
||||
"#,
|
||||
"absolute /dev path",
|
||||
),
|
||||
(
|
||||
"container network namespace",
|
||||
r#"
|
||||
app:
|
||||
id: bad-network
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
network: container:host
|
||||
"#,
|
||||
"not allowed",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, yaml, expected) in cases {
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains(expected),
|
||||
"case {name} expected '{expected}', got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_host_bind_exceptions_parse() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: reviewed-binds
|
||||
name: Reviewed Binds
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /run/user/1000/podman/podman.sock
|
||||
target: /var/run/docker.sock
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/run/dbus
|
||||
target: /var/run/dbus
|
||||
options: [ro]
|
||||
"#;
|
||||
AppManifest::parse(yaml).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_every_real_manifest() {
|
||||
let app_manifests = list_repo_manifests();
|
||||
@@ -1099,7 +1723,6 @@ app:
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
let mut modern_count = 0usize;
|
||||
let mut legacy_count = 0usize;
|
||||
for path in app_manifests {
|
||||
let content = fs::read_to_string(&path).expect("read manifest");
|
||||
let parsed_yaml: serde_yaml::Value = match serde_yaml::from_str(&content) {
|
||||
@@ -1121,15 +1744,14 @@ app:
|
||||
failures.push(format!("{}: {err}", path.display()));
|
||||
}
|
||||
} else {
|
||||
legacy_count += 1;
|
||||
failures.push(format!(
|
||||
"{}: expected modern app-schema manifest",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(modern_count > 0, "no modern app-schema manifests found");
|
||||
assert!(
|
||||
legacy_count > 0,
|
||||
"expected at least one legacy manifest shape"
|
||||
);
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::manifest::AppManifest;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Body, Request, Uri};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
@@ -56,9 +56,9 @@ pub enum ContainerState {
|
||||
impl From<&str> for ContainerState {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" => ContainerState::Created,
|
||||
"created" | "initialized" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopping" => ContainerState::Stopping,
|
||||
"stopping" | "removing" => ContainerState::Stopping,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
@@ -109,33 +109,22 @@ impl PodmanClient {
|
||||
|
||||
/// Map container name to its UI launch URL
|
||||
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||
if let Some(url) = manifest_lan_address_for(name) {
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let url = match name {
|
||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
||||
"homeassistant" => "http://localhost:8123",
|
||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||
"btcpay-server" => "http://localhost:23000",
|
||||
"grafana" => "http://localhost:3000",
|
||||
"searxng" => "http://localhost:8888",
|
||||
"ollama" => "http://localhost:11434",
|
||||
"cryptpad" => "http://localhost:3003",
|
||||
"penpot" => "http://localhost:9001",
|
||||
"nextcloud" => "http://localhost:8085",
|
||||
"vaultwarden" => "http://localhost:8082",
|
||||
"gitea" => "http://localhost:3001",
|
||||
"jellyfin" => "http://localhost:8096",
|
||||
"photoprism" => "http://localhost:2342",
|
||||
"immich_server" | "immich" => "http://localhost:2283",
|
||||
"filebrowser" => "http://localhost:8083",
|
||||
"nginx-proxy-manager" => "http://localhost:81",
|
||||
"portainer" => "http://localhost:9000",
|
||||
"uptime-kuma" => "http://localhost:3002",
|
||||
"fedimint" | "fedimintd" => "http://localhost:8175",
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
"nostr-rs-relay" => "http://localhost:18081",
|
||||
"indeedhub" => "http://localhost:7778",
|
||||
"dwn" => "http://localhost:3100",
|
||||
"endurain" => "http://localhost:8080",
|
||||
"netbird" => "http://localhost:8087",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
_ => return None,
|
||||
};
|
||||
@@ -388,7 +377,7 @@ impl PodmanClient {
|
||||
"cap_add": cap_add,
|
||||
"cap_drop": cap_drop,
|
||||
"read_only_filesystem": manifest.app.security.readonly_root,
|
||||
"no_new_privileges": true,
|
||||
"no_new_privileges": manifest.app.security.no_new_privileges,
|
||||
"restart_policy": "unless-stopped",
|
||||
"restart_tries": 5,
|
||||
"netns": {
|
||||
@@ -633,6 +622,7 @@ fn podman_network_settings(
|
||||
Some("bridge") => ("bridge", None),
|
||||
Some("none") => ("none", None),
|
||||
Some("slirp4netns") => ("slirp4netns", None),
|
||||
Some("pasta") => ("pasta", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
None if network_policy == "host" => ("host", None),
|
||||
@@ -660,6 +650,100 @@ fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
ports
|
||||
}
|
||||
|
||||
fn manifest_lan_address_for(container_name: &str) -> Option<String> {
|
||||
for apps_dir in manifest_apps_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(apps_dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(manifest) = AppManifest::parse(&contents) else {
|
||||
continue;
|
||||
};
|
||||
if manifest_runtime_names(&manifest)
|
||||
.iter()
|
||||
.any(|name| name == container_name)
|
||||
{
|
||||
if let Some(url) = manifest_primary_interface_url(&manifest) {
|
||||
return Some(url);
|
||||
}
|
||||
if manifest_has_http_health(&manifest) {
|
||||
if let Some(port) = manifest
|
||||
.app
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.protocol.eq_ignore_ascii_case("tcp"))
|
||||
.map(|port| port.host)
|
||||
{
|
||||
return Some(format!("http://localhost:{port}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
|
||||
let main = manifest.app.interfaces.get("main")?;
|
||||
if main.interface_type != "ui" {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}://localhost:{}{}",
|
||||
main.protocol, main.port, main.path
|
||||
))
|
||||
}
|
||||
|
||||
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
|
||||
manifest
|
||||
.app
|
||||
.health_check
|
||||
.as_ref()
|
||||
.is_some_and(|health| health.check_type.eq_ignore_ascii_case("http"))
|
||||
}
|
||||
|
||||
fn manifest_runtime_names(manifest: &AppManifest) -> Vec<String> {
|
||||
let mut names = vec![manifest_container_name(manifest)];
|
||||
match manifest.app.id.as_str() {
|
||||
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => names.push(manifest.app.id.clone()),
|
||||
"fedimint" => names.push("fedimintd".to_string()),
|
||||
"immich" => names.push("immich_server".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn manifest_container_name(manifest: &AppManifest) -> String {
|
||||
if let Some(v) = manifest.app.extensions.get("container_name") {
|
||||
if let Some(s) = v.as_str() {
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
match manifest.app.id.as_str() {
|
||||
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
|
||||
id => id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
}
|
||||
dirs.extend([
|
||||
Path::new("apps").to_path_buf(),
|
||||
Path::new("/opt/archipelago/apps").to_path_buf(),
|
||||
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
fn parse_memory_limit(limit: &str) -> Option<i64> {
|
||||
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
|
||||
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
|
||||
@@ -755,6 +839,30 @@ mod tests {
|
||||
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_address_uses_manifest_http_port_for_regular_apps() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("filebrowser").as_deref(),
|
||||
Some("http://localhost:8083")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_address_prefers_manifest_main_interface() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("fedimint").as_deref(),
|
||||
Some("http://localhost:8175/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_address_does_not_expose_tcp_only_service_ports() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("bitcoin-knots").as_deref(),
|
||||
Some("http://localhost:8334")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_iec_binary_suffixes() {
|
||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const PODMAN_CLI_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
#[async_trait]
|
||||
@@ -150,7 +151,25 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
if is_missing_container_error(&stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
|
||||
let zero_timeout = self.podman_cli(&["rm", "-f", "--time", "0", name]).await?;
|
||||
if zero_timeout.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = self.podman_cli(&["container", "cleanup", name]).await;
|
||||
let cleanup_rm = self.podman_cli(&["rm", "-f", name]).await?;
|
||||
if cleanup_rm.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let cleanup_stderr = String::from_utf8_lossy(&cleanup_rm.stderr);
|
||||
if is_missing_container_error(&cleanup_stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!(
|
||||
"podman rm fallback failed: {}; cleanup rm failed: {}",
|
||||
stderr.trim(),
|
||||
cleanup_stderr.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,20 +215,26 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
|
||||
// `podman image exists` returns 0 if present, 1 if absent. Any other
|
||||
// exit code is an environment failure we should surface.
|
||||
let output = self.podman_cli(&["image", "exists", image_ref]).await?;
|
||||
// Avoid `podman image exists`: on production nodes with a stressed
|
||||
// rootless store it can hang even when targeted at one image. A bounded
|
||||
// inspect is the local-storage probe the trait contract describes.
|
||||
let output = self
|
||||
.podman_cli_timeout(
|
||||
&["image", "inspect", image_ref],
|
||||
PODMAN_CLI_IMAGE_CHECK_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match output.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
Some(code) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(anyhow::anyhow!(
|
||||
"podman image exists {image_ref} exited with {code}: {stderr}"
|
||||
"podman image inspect {image_ref} exited with {code}: {stderr}"
|
||||
))
|
||||
}
|
||||
None => Err(anyhow::anyhow!(
|
||||
"podman image exists {image_ref} terminated by signal"
|
||||
"podman image inspect {image_ref} terminated by signal"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+336
-19
@@ -18,6 +18,7 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -555,6 +556,87 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 mb-8">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Transaction Relay Sharing</h2>
|
||||
<p class="text-white/70 text-sm">Trusted peer access for broadcasting transactions through this node</p>
|
||||
</div>
|
||||
<div class="px-3 py-2 bg-white/5 rounded-lg text-sm">
|
||||
<span class="text-white/60">Local node</span>
|
||||
<span class="ml-2 font-medium text-yellow-300" id="relaySyncStatus">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">HTTPS Endpoint</div>
|
||||
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayHttpsEndpoint">Not configured</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">HTTP Endpoint</div>
|
||||
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayHttpEndpoint">Not configured</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="text-xs uppercase tracking-wide text-white/50 mb-2">Tor Endpoint</div>
|
||||
<div class="text-sm text-white/80 font-mono break-all min-h-[1.5rem]" id="relayTorEndpoint">Not configured</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/80 text-sm">Allow peer use</span>
|
||||
<input id="relayEnabledToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/80 text-sm">Allow requests</span>
|
||||
<input id="relayRequestsToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/80 text-sm">Serve over Tor</span>
|
||||
<input id="relayTorToggle" type="checkbox" class="h-5 w-5 accent-orange-500" onchange="saveRelaySettings()">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<input id="relayHttpsInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="https://rpc.example.com/">
|
||||
<input id="relayHttpInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="http://192.168.1.2/">
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3">
|
||||
<input id="relayTorInput" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35" placeholder="http://exampleonion.onion/">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm font-medium" onclick="createRelayTorService()">Create Tor</button>
|
||||
</div>
|
||||
<button class="gradient-button px-4 py-2 rounded-lg text-sm font-medium" onclick="saveRelaySettings()">Save Sharing Settings</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3">
|
||||
<select id="relayPeerSelect" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white" onchange="saveRelaySettings()">
|
||||
<option value="">No trusted nodes available</option>
|
||||
</select>
|
||||
<button id="relayRequestButton" class="glass-button px-4 py-2 rounded-lg text-sm font-medium" onclick="requestPeerRelay()">Request Access</button>
|
||||
</div>
|
||||
<textarea id="relayRequestMessage" class="w-full px-3 py-2 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder-white/35 min-h-[5rem]" placeholder="Optional note for the peer"></textarea>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-white/70 text-sm">Restricted RPC user</span>
|
||||
<span class="text-white/90 text-sm font-mono" id="relayCredentialUser">txrelay</span>
|
||||
</div>
|
||||
<div class="text-xs mt-2 text-white/50" id="relayCredentialStatus">Credential status unavailable</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-white mb-2">Relay Requests</div>
|
||||
<div class="space-y-2" id="relayRequestsList">
|
||||
<div class="text-sm text-white/50 p-3 bg-white/5 rounded-lg">No relay requests</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-white/60" id="relayStatusMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@@ -608,6 +690,7 @@
|
||||
// RPC Configuration - Use local Nginx proxy within container
|
||||
const RPC_ENDPOINT = 'bitcoin-rpc/';
|
||||
const STATUS_ENDPOINT = 'bitcoin-status';
|
||||
const ARCHY_RPC_ENDPOINT = 'rpc/v1';
|
||||
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
|
||||
|
||||
// Make RPC call to Bitcoin node via local proxy
|
||||
@@ -654,6 +737,232 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function cookieValue(name) {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith(`${name}=`))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') || '';
|
||||
}
|
||||
|
||||
async function callArchyRPC(method, params = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const csrf = cookieValue('csrf');
|
||||
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
|
||||
const response = await fetch(ARCHY_RPC_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ method, params })
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok || body.error) {
|
||||
throw new Error(body.error?.message || `Archipelago RPC ${response.status}`);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function setText(id, value, fallback = 'Not configured') {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value || fallback;
|
||||
}
|
||||
|
||||
function setTextIfPresent(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
return el;
|
||||
}
|
||||
|
||||
function setWidthIfPresent(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = value;
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderRelayRequests(requests = []) {
|
||||
const list = document.getElementById('relayRequestsList');
|
||||
if (!list) return;
|
||||
if (!requests.length) {
|
||||
list.innerHTML = '<div class="text-sm text-white/50 p-3 bg-white/5 rounded-lg">No relay requests</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = requests.map(req => {
|
||||
const name = escapeHtml(req.peer_name || req.peer_onion || req.peer_pubkey);
|
||||
const message = req.message ? `<div class="text-xs text-white/50 mt-1">${escapeHtml(req.message)}</div>` : '';
|
||||
const endpoint = req.approved_endpoint ? `<div class="text-xs text-white/50 mt-1 font-mono break-all">${escapeHtml(req.approved_endpoint)}</div>` : '';
|
||||
const statusClass = req.status === 'approved'
|
||||
? 'text-green-300'
|
||||
: req.status === 'rejected'
|
||||
? 'text-red-300'
|
||||
: 'text-yellow-300';
|
||||
const actions = req.direction === 'incoming' && req.status === 'pending'
|
||||
? `<div class="flex gap-2 mt-3">
|
||||
<button class="glass-button px-3 py-2 rounded-lg text-xs font-medium" onclick="approveRelayRequest('${escapeHtml(req.id)}')">Approve</button>
|
||||
<button class="glass-button px-3 py-2 rounded-lg text-xs font-medium" onclick="rejectRelayRequest('${escapeHtml(req.id)}')">Reject</button>
|
||||
</div>`
|
||||
: '';
|
||||
return `<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-white/80">${name}</div>
|
||||
<div class="text-xs uppercase ${statusClass}">${escapeHtml(req.direction)} · ${escapeHtml(req.status)}</div>
|
||||
</div>
|
||||
${message}
|
||||
${endpoint}
|
||||
${actions}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRelayPeers(peers = [], selectedPeer = '', localSynced = true) {
|
||||
const select = document.getElementById('relayPeerSelect');
|
||||
const button = document.getElementById('relayRequestButton');
|
||||
if (!select) return;
|
||||
if (!localSynced) {
|
||||
select.innerHTML = '<option value="">Local Bitcoin node must finish syncing first</option>';
|
||||
select.disabled = true;
|
||||
if (button) button.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!peers.length) {
|
||||
select.innerHTML = '<option value="">No trusted nodes available</option>';
|
||||
select.disabled = true;
|
||||
if (button) button.disabled = true;
|
||||
return;
|
||||
}
|
||||
select.disabled = false;
|
||||
if (button) button.disabled = false;
|
||||
select.innerHTML = '<option value="">Choose a trusted node</option>' + peers.map(peer => {
|
||||
const label = escapeHtml(peer.name || peer.onion || peer.pubkey.slice(0, 16));
|
||||
const approved = peer.relay_approved ? ' · approved' : '';
|
||||
const selected = peer.pubkey === selectedPeer ? ' selected' : '';
|
||||
return `<option value="${escapeHtml(peer.pubkey)}"${selected}>${label}${approved}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadRelayAccess() {
|
||||
const statusEl = document.getElementById('relayStatusMessage');
|
||||
try {
|
||||
const relay = await callArchyRPC('bitcoin.relay-status');
|
||||
const settings = relay.settings || {};
|
||||
const local = relay.local_node || {};
|
||||
setText('relayHttpsEndpoint', settings.https_endpoint);
|
||||
setText('relayHttpEndpoint', settings.http_endpoint);
|
||||
setText('relayTorEndpoint', settings.tor_endpoint);
|
||||
const syncEl = document.getElementById('relaySyncStatus');
|
||||
if (syncEl) {
|
||||
syncEl.textContent = local.synced ? 'Synchronized' : 'Not synchronized';
|
||||
syncEl.className = local.synced ? 'ml-2 font-medium text-green-300' : 'ml-2 font-medium text-yellow-300';
|
||||
}
|
||||
const enabled = document.getElementById('relayEnabledToggle');
|
||||
const requests = document.getElementById('relayRequestsToggle');
|
||||
const tor = document.getElementById('relayTorToggle');
|
||||
if (enabled) enabled.checked = !!settings.enabled_for_peers;
|
||||
if (requests) requests.checked = !!settings.allow_peer_requests;
|
||||
if (tor) tor.checked = !!settings.allow_tor;
|
||||
const httpsInput = document.getElementById('relayHttpsInput');
|
||||
const httpInput = document.getElementById('relayHttpInput');
|
||||
const torInput = document.getElementById('relayTorInput');
|
||||
if (httpsInput && document.activeElement !== httpsInput) httpsInput.value = settings.https_endpoint || '';
|
||||
if (httpInput && document.activeElement !== httpInput) httpInput.value = settings.http_endpoint || '';
|
||||
if (torInput && document.activeElement !== torInput) torInput.value = settings.tor_endpoint || '';
|
||||
renderRelayPeers(relay.trusted_nodes || [], settings.selected_peer_pubkey || '', !!local.synced);
|
||||
renderRelayRequests(relay.requests || []);
|
||||
setText('relayCredentialUser', relay.credentials?.username || 'txrelay', 'txrelay');
|
||||
setText(
|
||||
'relayCredentialStatus',
|
||||
relay.credentials?.available ? `Credential file ready: ${relay.credentials.client_env_path}. ${relay.credentials.restart_hint || ''}` : 'Restricted relay credential will be generated when peer sharing is enabled',
|
||||
'Credential status unavailable'
|
||||
);
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
} catch (error) {
|
||||
console.warn('[Bitcoin UI] relay status failed', error);
|
||||
if (statusEl) statusEl.textContent = `Relay controls unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelaySettings() {
|
||||
const statusEl = document.getElementById('relayStatusMessage');
|
||||
const payload = {
|
||||
enabled_for_peers: !!document.getElementById('relayEnabledToggle')?.checked,
|
||||
allow_peer_requests: !!document.getElementById('relayRequestsToggle')?.checked,
|
||||
allow_tor: !!document.getElementById('relayTorToggle')?.checked,
|
||||
allow_https: !!document.getElementById('relayHttpsInput')?.value.trim(),
|
||||
allow_http: !!document.getElementById('relayHttpInput')?.value.trim(),
|
||||
selected_peer_pubkey: document.getElementById('relayPeerSelect')?.value || '',
|
||||
https_endpoint: document.getElementById('relayHttpsInput')?.value.trim() || '',
|
||||
http_endpoint: document.getElementById('relayHttpInput')?.value.trim() || '',
|
||||
tor_endpoint: document.getElementById('relayTorInput')?.value.trim() || ''
|
||||
};
|
||||
try {
|
||||
await callArchyRPC('bitcoin.relay-update-settings', payload);
|
||||
if (statusEl) statusEl.textContent = 'Relay settings saved.';
|
||||
await loadRelayAccess();
|
||||
} catch (error) {
|
||||
if (statusEl) statusEl.textContent = `Save failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPeerRelay() {
|
||||
const statusEl = document.getElementById('relayStatusMessage');
|
||||
const peer = document.getElementById('relayPeerSelect')?.value;
|
||||
if (!peer) {
|
||||
if (statusEl) statusEl.textContent = 'Choose a trusted node first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callArchyRPC('bitcoin.relay-request-peer', {
|
||||
peer_pubkey: peer,
|
||||
message: document.getElementById('relayRequestMessage')?.value || ''
|
||||
});
|
||||
if (statusEl) statusEl.textContent = 'Relay access request sent.';
|
||||
await loadRelayAccess();
|
||||
} catch (error) {
|
||||
if (statusEl) statusEl.textContent = `Request failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveRelayRequest(id) {
|
||||
await updateRelayRequest('bitcoin.relay-approve-request', id);
|
||||
}
|
||||
|
||||
async function rejectRelayRequest(id) {
|
||||
await updateRelayRequest('bitcoin.relay-reject-request', id);
|
||||
}
|
||||
|
||||
async function updateRelayRequest(method, id) {
|
||||
const statusEl = document.getElementById('relayStatusMessage');
|
||||
try {
|
||||
await callArchyRPC(method, { id });
|
||||
if (statusEl) statusEl.textContent = 'Relay request updated.';
|
||||
await loadRelayAccess();
|
||||
} catch (error) {
|
||||
if (statusEl) statusEl.textContent = `Update failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRelayTorService() {
|
||||
const statusEl = document.getElementById('relayStatusMessage');
|
||||
try {
|
||||
await callArchyRPC('bitcoin.relay-create-tor-service');
|
||||
if (statusEl) statusEl.textContent = 'Tor service requested.';
|
||||
await loadRelayAccess();
|
||||
} catch (error) {
|
||||
if (statusEl) statusEl.textContent = `Tor setup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation branding — detected from getnetworkinfo.subversion.
|
||||
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
|
||||
let brandingApplied = false;
|
||||
@@ -720,11 +1029,11 @@
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is reconnecting... showing last known values';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
} else if (consecutiveRpcFailures < 6) {
|
||||
syncStatusText.textContent = status.error || 'Connecting to Bitcoin node...';
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is starting or busy syncing...';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
} else {
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is not responding yet';
|
||||
syncStatusText.className = 'text-red-400 text-sm font-medium';
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is still syncing; retrying automatically...';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
if (syncIcon) {
|
||||
@@ -818,7 +1127,9 @@
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
rpcEl.textContent = status.stale
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const displayStale = status.stale === true && statusAgeMs > 30000;
|
||||
rpcEl.textContent = displayStale
|
||||
? `Reconnecting on port ${port}`
|
||||
: `Reachable on port ${port}`;
|
||||
}
|
||||
@@ -831,6 +1142,10 @@
|
||||
const isSynced = headers > 0 && blocks >= headers - 1 && !initialBlockDownload;
|
||||
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
|
||||
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
|
||||
const previousBlockCount = lastBlockCount;
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
|
||||
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;
|
||||
|
||||
// Calculate actual sync percentage based on blocks/headers
|
||||
const actualSyncValue = headers > 0 ? (blocks / headers) * 100 : 0;
|
||||
@@ -840,21 +1155,21 @@
|
||||
|
||||
// Animate block count if it changed
|
||||
const currentHeightElem = document.getElementById('currentHeight');
|
||||
if (blocks !== lastBlockCount && lastBlockCount > 0) {
|
||||
if (currentHeightElem && blocks !== lastBlockCount && lastBlockCount > 0) {
|
||||
currentHeightElem.classList.add('number-update');
|
||||
setTimeout(() => currentHeightElem.classList.remove('number-update'), 500);
|
||||
}
|
||||
lastBlockCount = blocks;
|
||||
|
||||
currentHeightElem.textContent = blocks.toLocaleString();
|
||||
document.getElementById('networkHeight').textContent = headers.toLocaleString();
|
||||
document.getElementById('headers').textContent = headers.toLocaleString();
|
||||
document.getElementById('verificationProgress').textContent = `${verificationPercentage}%`;
|
||||
document.getElementById('syncPercentage').textContent = `${actualSyncPercentage}%`;
|
||||
document.getElementById('currentBlock').textContent = appearsToBeReindexing
|
||||
setTextIfPresent('currentHeight', blocks.toLocaleString());
|
||||
setTextIfPresent('networkHeight', headers.toLocaleString());
|
||||
setTextIfPresent('headers', headers.toLocaleString());
|
||||
setTextIfPresent('verificationProgress', `${verificationPercentage}%`);
|
||||
setTextIfPresent('syncPercentage', `${actualSyncPercentage}%`);
|
||||
setTextIfPresent('currentBlock', appearsToBeReindexing
|
||||
? 'Reindexing from disk'
|
||||
: `Block ${blocks.toLocaleString()}`;
|
||||
document.getElementById('syncProgressBar').style.width = `${progressWidth}%`;
|
||||
: `Block ${blocks.toLocaleString()}`);
|
||||
setWidthIfPresent('syncProgressBar', `${progressWidth}%`);
|
||||
|
||||
// Update sync status text and icon
|
||||
const syncStatusText = document.getElementById('syncStatusText');
|
||||
@@ -868,10 +1183,10 @@
|
||||
syncIcon.classList.remove('text-green-500');
|
||||
}
|
||||
} else if (isSynced) {
|
||||
syncStatusText.textContent = status.stale
|
||||
syncStatusText.textContent = displayStale
|
||||
? 'Bitcoin node is reconnecting... showing last known synchronized state'
|
||||
: '✓ Fully synchronized with the network';
|
||||
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
|
||||
syncStatusText.className = displayStale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
|
||||
// Stop spinning when synced
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.remove('animate-spin-slow');
|
||||
@@ -879,12 +1194,12 @@
|
||||
}
|
||||
} else {
|
||||
const remaining = headers - blocks;
|
||||
syncStatusText.textContent = status.stale
|
||||
syncStatusText.textContent = displayStale
|
||||
? 'Bitcoin node is reconnecting... showing last known sync state'
|
||||
: initialBlockDownload
|
||||
? `Initial block download... ${remaining.toLocaleString()} blocks remaining`
|
||||
: `Syncing... ${remaining.toLocaleString()} blocks remaining`;
|
||||
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
|
||||
syncStatusText.className = displayStale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
|
||||
// Keep spinning while syncing
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
@@ -910,8 +1225,8 @@
|
||||
if (syncStatusText) {
|
||||
const hasRecentData = lastSuccessfulUpdateAt > 0 && Date.now() - lastSuccessfulUpdateAt < 120000;
|
||||
syncStatusText.textContent = hasRecentData
|
||||
? 'Bitcoin status bridge is reconnecting... keeping last known values'
|
||||
: 'Connecting to Bitcoin status bridge...';
|
||||
? 'Bitcoin status bridge is retrying... keeping last known values'
|
||||
: 'Bitcoin status bridge is starting...';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
@@ -920,10 +1235,12 @@
|
||||
// Initial update
|
||||
console.log('[Bitcoin UI] Starting initial blockchain info update...');
|
||||
updateBlockchainInfo();
|
||||
loadRelayAccess();
|
||||
|
||||
// Update every 5 seconds
|
||||
console.log('[Bitcoin UI] Setting up 5-second update interval');
|
||||
setInterval(updateBlockchainInfo, 5000);
|
||||
setInterval(loadRelayAccess, 15000);
|
||||
|
||||
function copyRPCInfo() {
|
||||
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: archipelago123\nRPC Endpoint: ${RPC_ENDPOINT}`;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY assets/img/bg-network.jpg /usr/share/nginx/html/assets/img/bg-network.jpg
|
||||
COPY assets/img/app-icons/fedimint.png /usr/share/nginx/html/assets/img/app-icons/fedimint.png
|
||||
COPY assets/img/app-icons/fedimint.jpg /usr/share/nginx/html/assets/img/app-icons/fedimint.jpg
|
||||
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
|
||||
EXPOSE 8175
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user