Compare commits
225
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 | ||
|
|
5818541721 | ||
|
|
b8053c00ca | ||
|
|
f95e9a1cd0 | ||
|
|
be50dc3235 | ||
|
|
2ff47f88a7 | ||
|
|
835c525218 | ||
|
|
3202b79e41 | ||
|
|
c0751e2551 | ||
|
|
1a0d8a432c | ||
|
|
745cb1c626 | ||
|
|
10fbb8f87c | ||
|
|
aad0ba5234 | ||
|
|
281e65e697 | ||
|
|
384f12de7a | ||
|
|
bd96c0475d | ||
|
|
9a89a000d4 | ||
|
|
97ce23d773 | ||
|
|
65576bd755 | ||
|
|
5b2e02bd43 | ||
|
|
9becafafd3 | ||
|
|
5074572373 | ||
|
|
ec1dce93a9 | ||
|
|
b9eb6eb18a | ||
|
|
c55a4f4e86 | ||
|
|
01f416ae5d | ||
|
|
f80daff8ba | ||
|
|
1103c2c710 | ||
|
|
1b6c500657 | ||
|
|
5be2febe13 | ||
|
|
6bbe1b96cf | ||
|
|
8f13298805 | ||
|
|
ba2eece9aa | ||
|
|
6603227874 | ||
|
|
27ff1d5b52 | ||
|
|
f9e34fd0c6 | ||
|
|
23c4e7441f | ||
|
|
2bf8181110 | ||
|
|
0684491072 | ||
|
|
05e6c2e738 | ||
|
|
be9f9528c3 | ||
|
|
7ab788d178 | ||
|
|
f507b847ef | ||
|
|
8a2899ab4a | ||
|
|
992b673b20 | ||
|
|
4ec6ca98c1 | ||
|
|
dffa7e99bb | ||
|
|
8f83b37d51 | ||
|
|
4d05705315 | ||
|
|
05b41f8946 | ||
|
|
ed73e4709b | ||
|
|
0bd4e49a8c | ||
|
|
310c709aba | ||
|
|
dbf755e908 | ||
|
|
2572688468 | ||
|
|
4bf35f95e6 | ||
|
|
4edc420459 | ||
|
|
7af048cc1a | ||
|
|
2843cc1e84 | ||
|
|
c5ea41d0cb | ||
|
|
9d42645aa3 | ||
|
|
f6efe2f356 | ||
|
|
c4efb30382 | ||
|
|
cd6f8bad70 | ||
|
|
9f3d66e24e | ||
|
|
a272a79706 | ||
|
|
694e5b0a9d | ||
|
|
0f1ad47aec | ||
|
|
06dcdafda4 | ||
|
|
92612ddc70 | ||
|
|
353825b66c | ||
|
|
12f93cc15e | ||
|
|
4faac9cb74 | ||
|
|
b62b731db0 | ||
|
|
6c8cb50679 | ||
|
|
28e38a36a9 | ||
|
|
d9d5fa65e5 | ||
|
|
980c1b25f4 | ||
|
|
7e62ea07f7 | ||
|
|
576ff1a6de | ||
|
|
49b98e0271 | ||
|
|
702b5d64d3 | ||
|
|
1ad889608f | ||
|
|
0ea4f96de9 | ||
|
|
a8158b1ef5 | ||
|
|
cd69c3b2f6 | ||
|
|
39dd1d9dcc | ||
|
|
5baced5f5b | ||
|
|
cad63bdd76 | ||
|
|
bb2e3fab42 | ||
|
|
6a5fab709a | ||
|
|
2a2f10608b | ||
|
|
7257f72f4a | ||
|
|
30b31b3670 | ||
|
|
28819d1197 | ||
|
|
80765c5755 | ||
|
|
8acf7d1112 | ||
|
|
c396be8068 | ||
|
|
236a2dee85 | ||
|
|
758d3e47d8 | ||
|
|
3e9c192b48 | ||
|
|
ba8bd0bb86 | ||
|
|
6a0809d386 | ||
|
|
81c1613040 | ||
|
|
89199bb03b | ||
|
|
ca299e70e8 | ||
|
|
40a6eaca72 | ||
|
|
e103925a4e | ||
|
|
56af57a6f8 | ||
|
|
919055f3f1 | ||
|
|
0ac673deb4 | ||
|
|
d1bcf271f9 | ||
|
|
85417de952 | ||
|
|
b8d084368e | ||
|
|
36a6101026 | ||
|
|
cfc98c600e | ||
|
|
e206e1fc94 | ||
|
|
9cf1177b73 | ||
|
|
a7048f6d8e | ||
|
|
06feb85aa5 | ||
|
|
aa0677be57 | ||
|
|
974fce5870 | ||
|
|
682b93f2d6 | ||
|
|
18f0929614 | ||
|
|
1709149ebd | ||
|
|
2664074210 | ||
|
|
9868991900 | ||
|
|
0d15ca588a | ||
|
|
1c1416cc1a | ||
|
|
1735098d81 | ||
|
|
b5da6875d7 | ||
|
|
4b6a088e38 | ||
|
|
f8304aed90 | ||
|
|
40f76013dc |
@@ -7,6 +7,14 @@
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Allow backend source for ISO source builds
|
||||
!core/
|
||||
!scripts/
|
||||
!image-recipe/
|
||||
image-recipe/build/
|
||||
image-recipe/results/
|
||||
image-recipe/output/
|
||||
|
||||
# Exclude nested node_modules (will npm install in container)
|
||||
neode-ui/node_modules
|
||||
neode-ui/dist
|
||||
|
||||
+23
@@ -57,6 +57,11 @@ coverage/
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||
releases/**
|
||||
!releases/
|
||||
!releases/manifest.json
|
||||
|
||||
# macOS build output
|
||||
build/macos/
|
||||
|
||||
@@ -73,3 +78,21 @@ loop/loop.log.bak
|
||||
# Separate repos nested in tree
|
||||
web/
|
||||
|
||||
._*
|
||||
|
||||
# Resilience harness reports (generated, contains session cookies)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+332
@@ -1,5 +1,337 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.85-alpha (2026-06-12)
|
||||
|
||||
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
|
||||
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
|
||||
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
|
||||
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
|
||||
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
|
||||
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
|
||||
|
||||
## v1.7.84-alpha (2026-06-11)
|
||||
|
||||
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
|
||||
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
|
||||
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
|
||||
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
|
||||
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
|
||||
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
|
||||
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
|
||||
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
|
||||
|
||||
## v1.7.83-alpha (2026-06-11)
|
||||
|
||||
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
|
||||
- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release.
|
||||
- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing.
|
||||
- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring.
|
||||
- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient.
|
||||
- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy.
|
||||
- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase.
|
||||
|
||||
## v1.7.82-alpha (2026-05-22)
|
||||
|
||||
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
|
||||
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
|
||||
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
|
||||
|
||||
## v1.7.81-alpha (2026-05-21)
|
||||
|
||||
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
|
||||
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
|
||||
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
|
||||
|
||||
## v1.7.80-alpha (2026-05-21)
|
||||
|
||||
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
|
||||
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
|
||||
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
|
||||
|
||||
## v1.7.79-alpha (2026-05-20)
|
||||
|
||||
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
|
||||
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
|
||||
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
|
||||
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
|
||||
|
||||
## v1.7.78-alpha (2026-05-20)
|
||||
|
||||
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
|
||||
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
|
||||
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
|
||||
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
|
||||
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
|
||||
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
|
||||
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
|
||||
|
||||
## v1.7.77-alpha (2026-05-20)
|
||||
|
||||
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
|
||||
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
|
||||
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
|
||||
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
|
||||
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
|
||||
|
||||
## v1.7.76-alpha (2026-05-20)
|
||||
|
||||
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
|
||||
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
|
||||
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
|
||||
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
|
||||
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
|
||||
|
||||
## v1.7.75-alpha (2026-05-19)
|
||||
|
||||
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
|
||||
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
|
||||
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
|
||||
|
||||
## v1.7.74-alpha (2026-05-19)
|
||||
|
||||
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
|
||||
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
|
||||
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
|
||||
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.73-alpha (2026-05-19)
|
||||
|
||||
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||
|
||||
## v1.7.72-alpha (2026-05-19)
|
||||
|
||||
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
|
||||
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
|
||||
|
||||
## v1.7.71-alpha (2026-05-19)
|
||||
|
||||
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
|
||||
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
|
||||
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
|
||||
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
|
||||
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
|
||||
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.70-alpha (2026-05-19)
|
||||
|
||||
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
|
||||
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
|
||||
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
|
||||
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
|
||||
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.69-alpha (2026-05-19)
|
||||
|
||||
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
|
||||
- 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)
|
||||
|
||||
- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||
- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.
|
||||
- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired.
|
||||
|
||||
## v1.7.54-alpha (2026-05-06)
|
||||
|
||||
- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.
|
||||
- LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.
|
||||
- OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.
|
||||
- Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.
|
||||
- `.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana.
|
||||
|
||||
## v1.7.53-alpha (2026-05-05)
|
||||
|
||||
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.
|
||||
- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.
|
||||
- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror.
|
||||
|
||||
## v1.7.52-alpha (2026-05-05)
|
||||
|
||||
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
|
||||
- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.
|
||||
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
|
||||
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
|
||||
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
|
||||
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
|
||||
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
|
||||
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
|
||||
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
|
||||
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
|
||||
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
|
||||
|
||||
## v1.7.45-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password.
|
||||
- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end.
|
||||
- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created.
|
||||
- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install.
|
||||
- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array.
|
||||
- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io.
|
||||
- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path).
|
||||
- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load.
|
||||
|
||||
## v1.7.44-alpha (2026-04-28)
|
||||
|
||||
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||
72dec5aa fix(lnd-ui): align container port across all specs
|
||||
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||
|
||||
|
||||
All notable changes to Archipelago will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
+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)
|
||||
@@ -101,14 +101,20 @@ npm run build # Production build → web/dist/neode-ui/
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||
```
|
||||
|
||||
### Build ISO
|
||||
### Release (tarball-only)
|
||||
|
||||
Releases ship as a backend binary and a frontend tarball referenced by
|
||||
`releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
|
||||
|
||||
```bash
|
||||
ssh archipelago@<server>
|
||||
cd ~/archy/image-recipe
|
||||
sudo ./build-auto-installer-iso.sh
|
||||
./scripts/create-release.sh 1.2.3
|
||||
git push gitea-local main --tags
|
||||
git push gitea-vps2 main --tags
|
||||
```
|
||||
|
||||
ISO builds are archived under `image-recipe/_archived/` and not part of the
|
||||
release deliverable.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
+449
-156
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 2,
|
||||
"updated": "2026-04-12T00:00:00Z",
|
||||
"registry": "git.tx1138.com/lfg2025",
|
||||
"updated": "2026-04-22T00:00:00Z",
|
||||
"registry": "146.59.87.168:3000/lfg2025",
|
||||
"featured": {
|
||||
"id": "indeedhub",
|
||||
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||
@@ -11,232 +11,525 @@
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
|
||||
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
|
||||
"author": "Bitcoin Knots",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd", "title": "LND", "version": "0.18.4",
|
||||
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"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",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"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", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"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": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
|
||||
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": ["bitcoin-knots", "electrumx"]
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||
"author": "Luke Childs", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
|
||||
"description": "Bitcoin documentary streaming with Nostr identity.",
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "botfights", "title": "BotFights", "version": "1.1.0",
|
||||
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
|
||||
"id": "botfights",
|
||||
"title": "BotFights",
|
||||
"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.",
|
||||
"icon": "/assets/img/app-icons/botfights.svg",
|
||||
"author": "BotFights", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea", "title": "Gitea", "version": "1.23",
|
||||
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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",
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
"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="
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser", "title": "File Browser", "version": "2.27.0",
|
||||
"description": "Web-based file manager.",
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser", "category": "data", "tier": "core",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
|
||||
"description": "Privacy-respecting metasearch engine.",
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
"author": "SearXNG",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
|
||||
"description": "Your own Nostr relay. Store events locally, relay for friends.",
|
||||
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
|
||||
"author": "scsiblade", "category": "nostr",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
|
||||
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
|
||||
},
|
||||
{
|
||||
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint",
|
||||
"version": "0.10.0",
|
||||
"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": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "ollama", "title": "Ollama", "version": "0.5.4",
|
||||
"description": "Run AI models locally. Private and on your hardware.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama"
|
||||
"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": "nextcloud", "title": "Nextcloud", "version": "28",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
},
|
||||
{
|
||||
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich", "title": "Immich", "version": "1.90.0",
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "1.90.0",
|
||||
"description": "High-performance photo and video backup with ML.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
|
||||
"description": "Open-source home automation.",
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"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": "git.tx1138.com/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grafana", "title": "Grafana", "version": "10.2.0",
|
||||
"description": "Analytics and monitoring dashboards.",
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"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": "git.tx1138.com/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana"
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale", "title": "Tailscale", "version": "1.78.0",
|
||||
"id": "tailscale",
|
||||
"title": "Tailscale",
|
||||
"version": "1.78.0",
|
||||
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||
"author": "Tailscale", "category": "networking", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||
"author": "Tailscale",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"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 & 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": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-hosted uptime monitoring.",
|
||||
"icon": "/assets/img/app-icons/uptime-kuma.webp",
|
||||
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
"author": "Uptime Kuma",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
|
||||
"description": "Tailscale-style mesh VPN with Nostr control plane.",
|
||||
"icon": "/assets/img/app-icons/nostr-vpn.svg",
|
||||
"author": "Martti Malmi", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
|
||||
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
|
||||
},
|
||||
{
|
||||
"id": "fips", "title": "FIPS", "version": "0.1.0",
|
||||
"description": "Free Internetworking Peering System. Encrypted mesh network.",
|
||||
"icon": "/assets/img/app-icons/fips.svg",
|
||||
"author": "Jim Corgan", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
|
||||
"repoUrl": "https://github.com/jmcorgan/fips"
|
||||
},
|
||||
{
|
||||
"id": "routstr", "title": "Routstr", "version": "0.4.3",
|
||||
"description": "Decentralized AI inference proxy with Cashu ecash.",
|
||||
"icon": "/assets/img/app-icons/routstr.svg",
|
||||
"author": "Routstr", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
|
||||
"repoUrl": "https://github.com/routstr/routstr-core"
|
||||
},
|
||||
{
|
||||
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
|
||||
"description": "Own your data with DID-based access control.",
|
||||
"icon": "/assets/img/app-icons/dwn.svg",
|
||||
"author": "TBD", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
|
||||
"repoUrl": "https://github.com/TBD54566975/dwn-server"
|
||||
},
|
||||
{
|
||||
"id": "endurain", "title": "Endurain", "version": "0.8.0",
|
||||
"description": "Self-hosted fitness tracking. Strava alternative.",
|
||||
"icon": "/assets/img/app-icons/endurain.png",
|
||||
"author": "Endurain", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
|
||||
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
|
||||
},
|
||||
{
|
||||
"id": "penpot", "title": "Penpot", "version": "2.4",
|
||||
"description": "Open-source design platform. Self-hosted Figma alternative.",
|
||||
"icon": "/assets/img/app-icons/penpot.webp",
|
||||
"author": "Penpot", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||
"repoUrl": "https://github.com/penpot/penpot"
|
||||
},
|
||||
{
|
||||
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"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:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"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
|
||||
|
||||
|
||||
+1
-5
@@ -17,15 +17,13 @@ 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, 8080 | TCP | P2P, gRPC, REST | 19735, 20009, 18080 |
|
||||
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
|
||||
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
|
||||
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
|
||||
| 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
app:
|
||||
id: archy-btcpay-db
|
||||
name: BTCPay Postgres
|
||||
version: 15.17
|
||||
description: Postgres backend for BTCPay and NBXplorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/postgres:15.17
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
secret_env:
|
||||
- key: POSTGRES_PASSWORD
|
||||
secret_file: btcpay-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/postgres-btcpay
|
||||
target: /var/lib/postgresql/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- POSTGRES_DB=btcpay
|
||||
- POSTGRES_USER=btcpay
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:5432
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,51 @@
|
||||
app:
|
||||
id: archy-mempool-db
|
||||
name: Mempool MariaDB
|
||||
version: 11.4.10
|
||||
description: MariaDB backend for the mempool explorer stack.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mariadb:11.4.10
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
secret_env:
|
||||
- key: MYSQL_PASSWORD
|
||||
secret_file: mempool-db-password
|
||||
- key: MYSQL_ROOT_PASSWORD
|
||||
secret_file: mysql-root-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/mysql-mempool
|
||||
target: /var/lib/mysql
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MYSQL_DATABASE=mempool
|
||||
- MYSQL_USER=mempool
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3306
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,44 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.0
|
||||
description: Frontend web UI for mempool explorer.
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
dependencies:
|
||||
- app_id: mempool-api
|
||||
version: ">=3.0.0"
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 4080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
- BACKEND_MAINNET_HTTP_HOST=mempool-api
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,64 @@
|
||||
app:
|
||||
id: archy-nbxplorer
|
||||
name: NBXplorer
|
||||
version: 2.6.0
|
||||
description: BTCPay blockchain indexer service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: NBXPLORER_BTCRPCPASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: archy-btcpay-db
|
||||
version: ">=15.17"
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 32838
|
||||
container: 32838
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nbxplorer
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- NBXPLORER_DATADIR=/data
|
||||
- NBXPLORER_NETWORK=mainnet
|
||||
- NBXPLORER_CHAINS=btc
|
||||
- NBXPLORER_BIND=0.0.0.0:32838
|
||||
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- NBXPLORER_BTCRPCUSER=archipelago
|
||||
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
|
||||
- NBXPLORER_NOAUTH=1
|
||||
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:32838
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
@@ -1,61 +1,94 @@
|
||||
app:
|
||||
id: bitcoin-core
|
||||
name: Bitcoin Core
|
||||
version: 24.0.0
|
||||
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
|
||||
|
||||
version: 28.4.0
|
||||
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-core
|
||||
|
||||
container:
|
||||
image: bitcoin/bitcoin:24.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
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=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 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
template: "{{DISK_GB}}"
|
||||
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:
|
||||
- storage: 500Gi # Minimum disk space for mainnet
|
||||
|
||||
- storage: 500Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [] # No special capabilities needed
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: bitcoin-core
|
||||
|
||||
|
||||
ports:
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp # RPC
|
||||
protocol: tcp
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp # P2P
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin
|
||||
target: /home/bitcoin/.bitcoin
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- NETWORK=mainnet
|
||||
- RPC_USER=${BITCOIN_RPC_USER}
|
||||
- RPC_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||
- PRUNE=0 # Full node (set to 550 for pruned)
|
||||
|
||||
- BITCOIN_RPC_USER=archipelago
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8332
|
||||
path: /
|
||||
type: tcp
|
||||
endpoint: localhost:8332
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
testnet_support: true
|
||||
testnet_support: false
|
||||
pruning_support: true
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
app:
|
||||
id: bitcoin-knots
|
||||
name: Bitcoin Knots
|
||||
version: 28.1.0
|
||||
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-knots
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
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 $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 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
template: "{{DISK_GB}}"
|
||||
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:
|
||||
- storage: 500Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 8Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin
|
||||
target: /home/bitcoin/.bitcoin
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIN_RPC_USER=archipelago
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8332
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
testnet_support: false
|
||||
pruning_support: true
|
||||
@@ -0,0 +1,56 @@
|
||||
app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP proxy + static site for interacting with the
|
||||
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
|
||||
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
|
||||
upstream Authorization header is substituted from
|
||||
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
|
||||
orchestrator's pre-start hook, rendered into an nginx.conf that is
|
||||
bind-mounted read-only at container start.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/bitcoin-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/bitcoin-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8334 directly on the host IP, and
|
||||
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
|
||||
# its RPC. `ports:` is intentionally empty because host networking
|
||||
# bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
|
||||
# and every reconcile pass, substituting the base64 RPC auth from
|
||||
# the plaintext password secret. If the rendered bytes change (the
|
||||
# password rotated, or the template was updated by OTA), the
|
||||
# reconciler restarts this container so nginx re-reads the config.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8334
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -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,66 +1,85 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.12.0
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
|
||||
container:
|
||||
image: btcpayserver/btcpayserver:1.12.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: BTCPAY_BTCRPCPASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
derived_env:
|
||||
- key: BTCPAY_HOST
|
||||
template: "{{HOST_IP}}:23000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: lnd
|
||||
version: ">=0.18.0"
|
||||
|
||||
- app_id: archy-btcpay-db
|
||||
version: ">=15.17"
|
||||
- app_id: archy-nbxplorer
|
||||
version: ">=2.6.0"
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: btcpay
|
||||
|
||||
|
||||
ports:
|
||||
- host: 80
|
||||
container: 80
|
||||
- host: 23000
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
- host: 443
|
||||
container: 443
|
||||
protocol: tcp
|
||||
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/btcpay
|
||||
target: /datadir
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- BTCPAY_NETWORK=mainnet
|
||||
- BTCPAY_CHAIN=btc
|
||||
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
|
||||
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
|
||||
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||
- BTCPAY_PROTOCOL=http
|
||||
- BTCPAY_CHAINS=btc
|
||||
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- BTCPAY_BTCRPCUSER=archipelago
|
||||
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost
|
||||
path: /health
|
||||
endpoint: http://localhost:49392
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
|
||||
|
||||
lightning_integration:
|
||||
payment_processing: true
|
||||
payment_processing: false
|
||||
invoice_management: true
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: BTCPay Server dashboard
|
||||
type: ui
|
||||
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'}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
app:
|
||||
id: electrs-ui
|
||||
name: Electrs UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
|
||||
nginx inside a container, serves static assets, and proxies
|
||||
/electrs-status to the archipelago backend on 127.0.0.1:5678.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/electrs-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/electrs-ui:local
|
||||
|
||||
dependencies: []
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:50002
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,64 @@
|
||||
app:
|
||||
id: electrumx
|
||||
name: ElectrumX
|
||||
version: 1.18.0
|
||||
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 6Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 50001
|
||||
container: 50001
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/electrumx
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 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 8080
|
||||
|
||||
ENV ENDURAIN_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,50 +0,0 @@
|
||||
app:
|
||||
id: endurain
|
||||
name: Endurain
|
||||
version: 1.0.0
|
||||
description: Endurain application platform. Custom application runtime.
|
||||
|
||||
container:
|
||||
image: archipelago/endurain:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: endurain
|
||||
|
||||
ports:
|
||||
- host: 8085
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/endurain
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- ENDURAIN_ENV=production
|
||||
- ENDURAIN_DATA_DIR=/app/data
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8085
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "endurain",
|
||||
"version": "1.0.0",
|
||||
"description": "Endurain application platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'endurain', version: '1.0.0' });
|
||||
});
|
||||
|
||||
// API endpoints
|
||||
app.get('/api/info', (req, res) => {
|
||||
res.json({
|
||||
name: 'Endurain',
|
||||
version: '1.0.0',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Endurain listening on port ${port}`);
|
||||
console.log(`Data directory: ${process.env.ENDURAIN_DATA_DIR || '/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"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
app:
|
||||
id: fedimint-gateway
|
||||
name: Fedimint Gateway
|
||||
version: 0.10.0
|
||||
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: FEDI_HASH
|
||||
secret_file: fedimint-gateway-hash
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: fedimint
|
||||
version: ">=0.10.0"
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8176
|
||||
container: 8176
|
||||
protocol: tcp
|
||||
- host: 9737
|
||||
container: 9737
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fedimint-gateway
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /lnd
|
||||
options: [ro]
|
||||
|
||||
environment:
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8176
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
+50
-25
@@ -3,56 +3,72 @@ app:
|
||||
name: Fedimint
|
||||
version: 0.10.0
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
|
||||
container:
|
||||
image: fedimint/fedimintd:v0.10.0
|
||||
image_signature: cosign://...
|
||||
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
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=24.0"
|
||||
version: ">=26.0"
|
||||
- storage: 20Gi
|
||||
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: fedimint
|
||||
|
||||
|
||||
ports:
|
||||
- host: 8173
|
||||
container: 8173
|
||||
protocol: tcp # P2P
|
||||
protocol: tcp
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp # API
|
||||
- host: 8175
|
||||
protocol: tcp
|
||||
# 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 # Built-in Guardian UI
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fedimint
|
||||
target: /fedimint
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/fedimint
|
||||
- FM_BITCOIND_URL=http://bitcoin-core:8332
|
||||
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
|
||||
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
- FM_BIND_API=0.0.0.0:8174
|
||||
- FM_BIND_UI=0.0.0.0:8175
|
||||
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8175
|
||||
@@ -60,7 +76,16 @@ app:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Guardian UI
|
||||
description: Fedimint Guardian wait/proxy UI
|
||||
type: ui
|
||||
port: 8175
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
app:
|
||||
id: filebrowser
|
||||
name: File Browser
|
||||
version: 2.27.0
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
data_uid: "100000:100000"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/filebrowser
|
||||
target: /srv
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/filebrowser-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:80
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
+81
-47
@@ -1,53 +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 which blocks iframe embedding.
|
||||
# Container binds to internal_port (3001), nginx proxies public port (3000)
|
||||
# stripping the X-Frame-Options header so the app works in Archipelago's 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
|
||||
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: grafana/grafana:10.2.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
data_uid: "472:472"
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
@@ -27,7 +28,7 @@ app:
|
||||
apparmor_profile: grafana
|
||||
|
||||
ports:
|
||||
- host: 3001
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@@ -40,13 +41,17 @@ app:
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3001
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||
- GF_INSTALL_PLUGINS=
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3001
|
||||
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: /api/
|
||||
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,13 +1,14 @@
|
||||
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: git.tx1138.com/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
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -27,9 +28,9 @@ app:
|
||||
apparmor_profile: default
|
||||
|
||||
ports:
|
||||
- host: 7777
|
||||
container: 3000
|
||||
protocol: tcp # Web UI (Next.js)
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
@@ -38,6 +39,12 @@ app:
|
||||
- type: tmpfs
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
- type: tmpfs
|
||||
target: /run
|
||||
options: [rw,nosuid,nodev,size=16m]
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,nosuid,nodev,size=32m]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
@@ -57,14 +64,15 @@ app:
|
||||
name: Web UI
|
||||
description: Stream Bitcoin documentaries with Nostr identity
|
||||
type: ui
|
||||
port: 7777
|
||||
port: 7778
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
app:
|
||||
id: lnd-ui
|
||||
name: LND UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for LND. Runs nginx inside a
|
||||
container and serves static assets. LND connection info is fetched
|
||||
via an absolute URL that the host nginx routes to the archipelago
|
||||
backend on 127.0.0.1:5678, so no upstream auth is baked in.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/lnd-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/lnd-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: lnd
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: bridge
|
||||
|
||||
# Bridge networking via archy-net. Container nginx listens on 80;
|
||||
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
|
||||
ports:
|
||||
- host: 18083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18083
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
+29
-31
@@ -1,67 +1,65 @@
|
||||
app:
|
||||
id: lnd
|
||||
name: Lightning Network Daemon
|
||||
version: 0.18.0
|
||||
name: LND
|
||||
version: 0.18.4
|
||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
|
||||
container:
|
||||
image: lightninglabs/lnd:v0.18.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: BITCOIND_RPCPASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100000:100000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: lnd
|
||||
|
||||
|
||||
ports:
|
||||
- host: 9735
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
protocol: tcp
|
||||
- host: 10009
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
- host: 8080
|
||||
protocol: tcp
|
||||
- host: 18080
|
||||
container: 8080
|
||||
protocol: tcp # REST
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /root/.lnd
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-core
|
||||
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
|
||||
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
|
||||
- BITCOIND_HOST=bitcoin-knots
|
||||
- BITCOIND_RPCUSER=archipelago
|
||||
- NETWORK=mainnet
|
||||
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /v1/getinfo
|
||||
type: tcp
|
||||
endpoint: localhost:10009
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
|
||||
lightning_integration:
|
||||
channel_management: true
|
||||
payment_routing: true
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
app:
|
||||
id: mempool-api
|
||||
name: Mempool API
|
||||
version: 3.0.0
|
||||
description: Backend API for mempool explorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: mempool-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- app_id: electrumx
|
||||
version: ">=1.18.0"
|
||||
- app_id: archy-mempool-db
|
||||
version: ">=11.4.10"
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8999
|
||||
container: 8999
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/mempool
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MEMPOOL_BACKEND=electrum
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
- DATABASE_HOST=archy-mempool-db
|
||||
- DATABASE_DATABASE=mempool
|
||||
- DATABASE_USERNAME=mempool
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8999
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: scsibug/nostr-rs-relay:0.8.9
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi # For event storage
|
||||
@@ -27,14 +28,14 @@ app:
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
ports:
|
||||
- host: 8081
|
||||
- host: 18081
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nostr-relay
|
||||
target: /app/db
|
||||
target: /usr/src/app/db
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
@@ -45,11 +46,11 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8081
|
||||
path: /health
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Ollama - uses official image
|
||||
FROM ollama/ollama:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,50 +0,0 @@
|
||||
app:
|
||||
id: ollama
|
||||
name: Ollama
|
||||
version: 0.1.0
|
||||
description: Run large language models locally. Privacy-preserving AI on your node.
|
||||
|
||||
container:
|
||||
image: ollama/ollama:0.6.2
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 50Gi # Models can be large
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 8Gi # LLMs need lots of RAM
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false # Ollama needs write access for models
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: ollama
|
||||
|
||||
ports:
|
||||
- host: 11434
|
||||
container: 11434
|
||||
protocol: tcp # API
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/ollama
|
||||
target: /root/.ollama
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- OLLAMA_HOST=0.0.0.0:11434
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:11434
|
||||
path: /api/tags
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
# Penpot - uses official image
|
||||
FROM penpot/penpot:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,51 +0,0 @@
|
||||
app:
|
||||
id: penpot
|
||||
name: Penpot
|
||||
version: 2.0.0
|
||||
description: Open-source design and prototyping platform. Design tools for teams.
|
||||
|
||||
container:
|
||||
image: penpotapp/frontend:2.13.3
|
||||
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: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: penpot
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
container: 80
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/penpot
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PENPOT_PUBLIC_URI=http://localhost:8089
|
||||
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
|
||||
- PENPOT_REDIS_URI=redis://penpot-redis:6379
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8089
|
||||
path: /api/health
|
||||
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
|
||||
@@ -1,12 +1,11 @@
|
||||
app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: 2024.1.0
|
||||
version: 1.0.0
|
||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||
|
||||
container:
|
||||
image: searxng/searxng:2024.1.0
|
||||
image_signature: cosign://...
|
||||
image: 146.59.87.168:3000/lfg2025/searxng:latest
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
@@ -43,8 +42,8 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8888
|
||||
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
+2
-1
@@ -80,13 +80,14 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.19-alpha"
|
||||
version = "1.7.85-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
"archipelago-performance",
|
||||
"archipelago-security",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"bcrypt",
|
||||
"bip39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.19-alpha"
|
||||
version = "1.7.85-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -103,6 +103,9 @@ mdns-sd = "0.18"
|
||||
# Systemd watchdog notification
|
||||
sd-notify = "0.4"
|
||||
|
||||
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -10,6 +10,7 @@ mod websocket;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::blobs::BlobStore;
|
||||
use crate::config::Config;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
@@ -54,6 +55,8 @@ impl ApiHandler {
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new().await;
|
||||
let rpc_handler = Arc::new(
|
||||
@@ -62,6 +65,8 @@ impl ApiHandler {
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@@ -115,14 +120,40 @@ impl ApiHandler {
|
||||
|
||||
/// Server-side fetch of the upstream app catalog so the browser can
|
||||
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the
|
||||
/// upstream URLs in the same order the frontend used, returns the
|
||||
/// first 2xx response. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
|
||||
const UPSTREAMS: &[&str] = &[
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
];
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||
/// list is derived from the operator's configured container registries
|
||||
/// so switching mirrors in Settings changes the App Store source too —
|
||||
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||
/// If registry config can't be loaded, falls back to the legacy
|
||||
/// hardcoded pair so the App Store still renders on nodes that haven't
|
||||
/// persisted a registry config yet. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||
let mut upstreams: Vec<String> = Vec::new();
|
||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||
{
|
||||
for reg in config.active_registries() {
|
||||
let scheme = if reg.tls_verify { "https" } else { "http" };
|
||||
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
|
||||
// reg.url already includes the namespace (e.g. "host/lfg2025"),
|
||||
// so we just tack on the repo + raw path.
|
||||
upstreams.push(format!(
|
||||
"{}://{}/app-catalog/raw/branch/main/catalog.json",
|
||||
scheme, reg.url
|
||||
));
|
||||
}
|
||||
}
|
||||
if upstreams.is_empty() {
|
||||
upstreams.push(
|
||||
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
upstreams.push(
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
@@ -136,8 +167,8 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
};
|
||||
for url in UPSTREAMS {
|
||||
match client.get(*url).send().await {
|
||||
for url in &upstreams {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
return Ok(Response::builder()
|
||||
@@ -289,7 +320,7 @@ impl ApiHandler {
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
@@ -398,6 +429,7 @@ impl ApiHandler {
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
@@ -408,7 +440,7 @@ impl ApiHandler {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
Self::handle_app_catalog_proxy().await
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::build_response;
|
||||
use crate::api::rpc::lnd::LND_REST_BASE_URL;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::bitcoin_status;
|
||||
use crate::electrs_status;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
@@ -76,11 +78,23 @@ impl ApiHandler {
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
@@ -110,7 +124,7 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -3,6 +3,55 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
|
||||
///
|
||||
/// Exposed as a struct (rather than hard-coded constants inside the function)
|
||||
/// so tests can dial down timeouts to keep the suite fast while still
|
||||
/// exercising real retry/backoff behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RetryConfig {
|
||||
max_attempts: u32,
|
||||
attempt_timeout: std::time::Duration,
|
||||
/// Length must equal `max_attempts - 1` (one backoff between each
|
||||
/// successive attempt). The last attempt is not followed by a backoff.
|
||||
backoffs: Vec<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Production retry policy: 3 attempts, 15s each, 500ms + 1500ms backoffs.
|
||||
/// Total worst-case wall time: 3 * 15 + 0.5 + 1.5 = 47s.
|
||||
fn production() -> Self {
|
||||
Self {
|
||||
max_attempts: BITCOIN_RPC_MAX_ATTEMPTS,
|
||||
attempt_timeout: BITCOIN_RPC_ATTEMPT_TIMEOUT,
|
||||
backoffs: BITCOIN_RPC_BACKOFFS.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Max retry attempts for a single bitcoin_rpc_call invocation.
|
||||
/// First attempt + 2 retries = 3 total.
|
||||
const BITCOIN_RPC_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Per-attempt deadline. Must be >= the reqwest client's own timeout (we
|
||||
/// build it at 15s in handle_bitcoin_getinfo) — this is the outer safety net.
|
||||
const BITCOIN_RPC_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Backoff between attempts. Index 0 = after first failure, 1 = after second, etc.
|
||||
/// Chosen to absorb bitcoind's typical block-validation stall (2-5s) without
|
||||
/// adding noticeable latency on the happy path (first attempt succeeds in ~30ms).
|
||||
const BITCOIN_RPC_BACKOFFS: [std::time::Duration; 2] = [
|
||||
std::time::Duration::from_millis(500),
|
||||
std::time::Duration::from_millis(1500),
|
||||
];
|
||||
|
||||
/// Classify a reqwest error as transient (retryable) or fatal.
|
||||
/// Transient: timeout, connect refused, request/response body IO errors.
|
||||
/// Fatal: TLS errors, URL parse errors, redirect loops, builder errors.
|
||||
fn is_transient_transport_error(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BitcoinInfo {
|
||||
block_height: u64,
|
||||
@@ -37,8 +86,15 @@ struct MempoolInfo {
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
|
||||
// Per-attempt timeout (see bitcoin_rpc_call for retry semantics).
|
||||
// 15s is enough room for bitcoind to answer getblockchaininfo even
|
||||
// during block validation; bitcoin_rpc_call wraps each attempt in a
|
||||
// separate tokio::time::timeout too, so this is belt-and-suspenders.
|
||||
// connect_timeout is tighter so a dead bitcoind doesn't steal the
|
||||
// whole attempt budget on TCP connect alone.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
@@ -68,6 +124,19 @@ impl RpcHandler {
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Call a Bitcoin Core JSON-RPC method.
|
||||
///
|
||||
/// Retries up to [`BITCOIN_RPC_MAX_ATTEMPTS`] times on transient
|
||||
/// transport errors (timeout / connection refused / send/recv IO).
|
||||
/// Does **not** retry when bitcoind responds with a well-formed
|
||||
/// `{"error": ...}` body — those are real RPC errors and surfacing
|
||||
/// them quickly is the right behavior.
|
||||
///
|
||||
/// Motivation: on a syncing pruned node, bitcoind's RPC thread can block
|
||||
/// for 5-10 seconds during block validation. A single 10s timeout means
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
@@ -75,33 +144,15 @@ impl RpcHandler {
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Bitcoin RPC connection failed")?;
|
||||
|
||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Bitcoin RPC response")?;
|
||||
|
||||
if let Some(err) = rpc_resp.error {
|
||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||
}
|
||||
|
||||
rpc_resp
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
|
||||
bitcoin_rpc_post_with_retry(
|
||||
client,
|
||||
crate::constants::BITCOIN_RPC_URL,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
method,
|
||||
params,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
@@ -243,3 +294,351 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
|
||||
///
|
||||
/// Takes the URL + credentials as parameters so it can be exercised by unit
|
||||
/// tests against a mock HTTP server without constructing a full `RpcHandler`.
|
||||
///
|
||||
/// Production callers go through `RpcHandler::bitcoin_rpc_call`, which loads
|
||||
/// credentials from the secrets file and points at `BITCOIN_RPC_URL`.
|
||||
async fn bitcoin_rpc_post_with_retry<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
bitcoin_rpc_post_with_retry_cfg(
|
||||
client,
|
||||
url,
|
||||
rpc_user,
|
||||
rpc_pass,
|
||||
method,
|
||||
params,
|
||||
&RetryConfig::production(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Inner implementation with configurable retry policy (for tests).
|
||||
async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
cfg: &RetryConfig,
|
||||
) -> Result<T> {
|
||||
debug_assert_eq!(
|
||||
cfg.backoffs.len(),
|
||||
(cfg.max_attempts - 1) as usize,
|
||||
"RetryConfig: backoffs.len() must equal max_attempts - 1"
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 0..cfg.max_attempts {
|
||||
if attempt > 0 {
|
||||
let backoff = cfg
|
||||
.backoffs
|
||||
.get(attempt as usize - 1)
|
||||
.copied()
|
||||
.unwrap_or_else(|| std::time::Duration::from_secs(2));
|
||||
tracing::warn!(
|
||||
"bitcoin_rpc({}): attempt {} failed, backing off {:?}",
|
||||
method,
|
||||
attempt,
|
||||
backoff
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
|
||||
// Per-attempt hard deadline. Independent of reqwest's built-in timeout
|
||||
// so we always cap total time even if reqwest blocks on something
|
||||
// weird (e.g., DNS starvation).
|
||||
let fut = client
|
||||
.post(url)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.json(&body)
|
||||
.send();
|
||||
|
||||
let send_result = match tokio::time::timeout(cfg.attempt_timeout, fut).await {
|
||||
Err(_elapsed) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Bitcoin RPC send timed out after {:?}",
|
||||
cfg.attempt_timeout
|
||||
));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Ok(r) => r,
|
||||
};
|
||||
|
||||
let resp = match send_result {
|
||||
Ok(r) => r,
|
||||
Err(e) if is_transient_transport_error(&e) => {
|
||||
last_err = Some(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Bitcoin RPC response")?;
|
||||
|
||||
if let Some(err) = rpc_resp.error {
|
||||
// RPC-level error: this is a real bitcoind response, not transient.
|
||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||
}
|
||||
|
||||
return rpc_resp
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
|
||||
}
|
||||
|
||||
Err(last_err
|
||||
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Spin up a mock bitcoind HTTP server that behaves according to `handler`.
|
||||
/// Returns the bound URL and a JoinHandle (dropped = server shutdown via the
|
||||
/// oneshot cancel channel).
|
||||
async fn spawn_mock<F, Fut>(
|
||||
handler: F,
|
||||
) -> (
|
||||
String,
|
||||
tokio::task::JoinHandle<()>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
)
|
||||
where
|
||||
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
|
||||
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
|
||||
{
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let make_svc = make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req| {
|
||||
let handler = handler.clone();
|
||||
async move { Ok::<_, Infallible>(handler(req).await) }
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::bind(&addr).serve(make_svc);
|
||||
let url = format!("http://{}", server.local_addr());
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let handle = tokio::spawn(async move {
|
||||
let graceful = server.with_graceful_shutdown(async {
|
||||
let _ = rx.await;
|
||||
});
|
||||
let _ = graceful.await;
|
||||
});
|
||||
(url, handle, tx)
|
||||
}
|
||||
|
||||
/// Reply body bitcoind would send for a successful getblockcount.
|
||||
fn ok_reply() -> Body {
|
||||
Body::from(r#"{"result":42,"error":null,"id":"archy"}"#)
|
||||
}
|
||||
|
||||
fn err_reply() -> Body {
|
||||
Body::from(r#"{"result":null,"error":{"code":-8,"message":"nope"},"id":"archy"}"#)
|
||||
}
|
||||
|
||||
/// Succeeds on first attempt — should not retry.
|
||||
#[tokio::test]
|
||||
async fn happy_path_first_attempt() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let v: u64 =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
|
||||
.await
|
||||
.expect("should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
|
||||
}
|
||||
|
||||
/// HTTP 503 with non-JSON body: produces a JSON-parse error which is NOT
|
||||
/// classified as transient. Must fail after first attempt.
|
||||
/// This guards against the tempting mistake of blanket-retrying every
|
||||
/// non-2xx response — which would mask real bitcoind misconfig.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_parse_errors() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Body::from("busy"))
|
||||
.unwrap()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err(), "non-JSON response should error out");
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"parse errors are not retryable"
|
||||
);
|
||||
}
|
||||
|
||||
/// Connect-refused (port closed) is the canonical transient transport
|
||||
/// error. Must exhaust BITCOIN_RPC_MAX_ATTEMPTS and the total elapsed
|
||||
/// time must include at least the sum of the backoffs.
|
||||
#[tokio::test]
|
||||
async fn retries_exhausted_on_persistent_connect_refused() {
|
||||
// Bind a port then immediately drop the listener so the port is closed.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let closed_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_millis(500))
|
||||
.build()
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(result.is_err(), "connect-refused should exhaust retries");
|
||||
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
|
||||
assert!(
|
||||
elapsed >= min_backoff,
|
||||
"should have backed off between retries (elapsed={:?}, expected at least {:?})",
|
||||
elapsed,
|
||||
min_backoff
|
||||
);
|
||||
}
|
||||
|
||||
/// The motivating scenario: first attempt times out (bitcoind busy),
|
||||
/// subsequent attempt succeeds. Uses a short test-only RetryConfig so
|
||||
/// the test runs in <1s instead of 15s.
|
||||
#[tokio::test]
|
||||
async fn retries_on_timeout_then_succeeds() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
// Mock server: first request hangs for 500ms, subsequent requests reply OK.
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
// Attempt timeout 100ms < server's 500ms sleep => first attempt times out.
|
||||
// Backoff 20ms between attempts.
|
||||
let cfg = RetryConfig {
|
||||
max_attempts: 3,
|
||||
attempt_timeout: std::time::Duration::from_millis(100),
|
||||
backoffs: vec![
|
||||
std::time::Duration::from_millis(20),
|
||||
std::time::Duration::from_millis(20),
|
||||
],
|
||||
};
|
||||
let v: u64 = bitcoin_rpc_post_with_retry_cfg(
|
||||
&client,
|
||||
&url,
|
||||
"user",
|
||||
"pass",
|
||||
"getblockcount",
|
||||
&[],
|
||||
&cfg,
|
||||
)
|
||||
.await
|
||||
.expect("second attempt should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert!(
|
||||
count.load(Ordering::SeqCst) >= 2,
|
||||
"expected at least 2 attempts (got {})",
|
||||
count.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
/// bitcoind returned a well-formed `{"error": ...}` body. Must NOT retry.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_on_rpc_level_error() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(err_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"RPC-level errors are not transient"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanity: retry budget invariants. Chosen to catch regressions where
|
||||
/// someone bumps these constants without realizing the total worst-case
|
||||
/// wall time implications.
|
||||
#[test]
|
||||
fn retry_budget_invariants() {
|
||||
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
|
||||
assert_eq!(
|
||||
BITCOIN_RPC_BACKOFFS.len(),
|
||||
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
|
||||
);
|
||||
// Total wall-time ceiling:
|
||||
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
|
||||
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
|
||||
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
|
||||
assert!(total < std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
use super::package::validate_app_id;
|
||||
use super::transitional::Op;
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
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(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
// The `container-install { manifest_path }` RPC is a dev-mode convenience
|
||||
// that points at an arbitrary YAML on disk. Production install happens via
|
||||
// the reconciler (BootReconciler, Step 5) and via the unified
|
||||
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
|
||||
// through a separate `container-install-by-id` RPC when needed.
|
||||
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
@@ -45,7 +56,7 @@ impl RpcHandler {
|
||||
let manifest: archipelago_container::AppManifest =
|
||||
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
|
||||
|
||||
let container_name = orchestrator
|
||||
let container_name = dev
|
||||
.install_container(&manifest, manifest_path)
|
||||
.await
|
||||
.context("Failed to install container")?;
|
||||
@@ -57,10 +68,6 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
@@ -68,22 +75,24 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.start_container(app_id)
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
// User explicitly started the app — clear the user-stopped marker so
|
||||
// crash recovery / health monitor won't second-guess it. Must happen
|
||||
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
|
||||
// side and the ordering contract crash recovery depends on).
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
Ok(serde_json::json!({ "status": "started" }))
|
||||
// spawn_transitional returns as soon as the background task is
|
||||
// launched (<1s). The UI sees Starting… immediately via WebSocket.
|
||||
self.spawn_transitional(Op::Start, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "starting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
@@ -91,21 +100,51 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.stop_container(app_id)
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
|
||||
// (crash recovery / health monitor inspect this flag concurrently
|
||||
// with the in-flight stop; see runtime.rs:145-148 for the package
|
||||
// path that also writes this in the same order).
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped" }))
|
||||
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
|
||||
// background; the RPC returns now with "stopping".
|
||||
self.spawn_transitional(Op::Stop, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopping" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
// Restart does not mark user-stopped (the user wants the app to
|
||||
// keep running). Clear the marker as a defensive measure in case a
|
||||
// prior stop left it set and the restart is intended to revive the
|
||||
// normal running state.
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
self.spawn_transitional(Op::Restart, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "restarting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_remove(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -119,7 +158,7 @@ impl RpcHandler {
|
||||
.unwrap_or(false);
|
||||
|
||||
orchestrator
|
||||
.remove_container(app_id, preserve_data)
|
||||
.remove(app_id, preserve_data)
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
@@ -133,39 +172,75 @@ 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)| {
|
||||
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 => "created",
|
||||
_ => "unknown",
|
||||
};
|
||||
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));
|
||||
}
|
||||
|
||||
// Fallback: scanner hasn't run yet, query podman directly
|
||||
// Fallback: scanner hasn't run yet, query the orchestrator directly.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
if let Ok(containers) = orchestrator.list_containers().await {
|
||||
if let Ok(containers) = orchestrator.list().await {
|
||||
if !containers.is_empty() {
|
||||
return Ok(serde_json::to_value(containers)?);
|
||||
}
|
||||
@@ -242,9 +317,10 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -253,21 +329,36 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let status = orchestrator
|
||||
.get_container_status(app_id)
|
||||
.await
|
||||
.context("Failed to get container status")?;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.status(&candidate).await {
|
||||
Ok(status) => return Ok(serde_json::to_value(status)?),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::to_value(status)?)
|
||||
// Fallback for alias drift: query podman directly by likely container
|
||||
// names so status checks stay useful during migration.
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(v) = inspect_container_state_value(&name).await {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container status"));
|
||||
}
|
||||
Err(anyhow::anyhow!("Failed to get container status"))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_logs(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -278,7 +369,7 @@ impl RpcHandler {
|
||||
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
@@ -291,12 +382,13 @@ impl RpcHandler {
|
||||
app_id: &str,
|
||||
lines: u32,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
@@ -307,47 +399,523 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
// If app_id is provided, get health for that app
|
||||
// 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()) {
|
||||
let health = orchestrator
|
||||
.get_health_status(app_id)
|
||||
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 tokio::time::timeout(
|
||||
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||
orchestrator.health(&candidate),
|
||||
)
|
||||
.await
|
||||
.context("Failed to get container health")?;
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
{
|
||||
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) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, get health for all containers
|
||||
// Otherwise, get health for all containers.
|
||||
let containers = orchestrator
|
||||
.list_containers()
|
||||
.list()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
let mut health_map = serde_json::Map::new();
|
||||
for container in containers {
|
||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||
match orchestrator.get_health_status(app_id).await {
|
||||
Ok(health) => {
|
||||
health_map
|
||||
.insert(app_id.to_string(), serde_json::Value::String(health));
|
||||
}
|
||||
Err(_) => {
|
||||
health_map.insert(
|
||||
app_id.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Map the runtime container name back to the app_id the orchestrator
|
||||
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
|
||||
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
|
||||
// app_id either way since UI_APP_IDS is centralised).
|
||||
let app_id_candidate = container
|
||||
.name
|
||||
.strip_prefix("archipelago-")
|
||||
.and_then(|s| s.strip_suffix("-dev"))
|
||||
.or_else(|| container.name.strip_prefix("archy-"))
|
||||
.unwrap_or(container.name.as_str());
|
||||
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),
|
||||
);
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let mut saw_starting = false;
|
||||
let mut saw_unknown = false;
|
||||
for member in members {
|
||||
match member_health(orchestrator.as_ref(), member)
|
||||
.await
|
||||
.as_deref()
|
||||
{
|
||||
Ok(health) if health == "healthy" => {}
|
||||
Ok(health) if health == "starting" => saw_starting = true,
|
||||
Ok(health) if health == "unknown" => saw_unknown = true,
|
||||
Ok(_) => return Ok(Some("unhealthy".to_string())),
|
||||
Err(_) => saw_unknown = true,
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn member_health(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
app_id: &str,
|
||||
) -> Result<String> {
|
||||
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) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(health);
|
||||
}
|
||||
}
|
||||
Ok("unknown".to_string())
|
||||
}
|
||||
|
||||
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
|
||||
match app_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
|
||||
}
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
|
||||
}
|
||||
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
|
||||
"indeedhub" => Some(&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub",
|
||||
]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" => {
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin-core");
|
||||
push("bitcoin");
|
||||
}
|
||||
"bitcoin-core" | "bitcoin" => {
|
||||
push("bitcoin-core");
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin");
|
||||
}
|
||||
"electrs" | "mempool-electrs" => {
|
||||
push("electrs");
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
|
||||
"bitcoin-ui" => push("archy-bitcoin-ui"),
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(app_id);
|
||||
if let Some(stripped) = app_id.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
} else {
|
||||
push(&format!("archy-{}", app_id));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let status = parts.next().unwrap_or("unknown");
|
||||
let running = parts.next().unwrap_or("false") == "true";
|
||||
let health = parts.next().unwrap_or("none");
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"state": status,
|
||||
"running": running,
|
||||
"health": health,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
&format!("name={name}"),
|
||||
"--format",
|
||||
"{{.Names}}|{{.Status}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
let mut parts = line.splitn(2, '|');
|
||||
let container_name = parts.next().unwrap_or_default();
|
||||
if container_name != name {
|
||||
continue;
|
||||
}
|
||||
let status = parts.next().unwrap_or_default();
|
||||
let state = state_from_podman_status(status);
|
||||
let health = parse_health_from_status(status).unwrap_or("none");
|
||||
return Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": state,
|
||||
"state": state,
|
||||
"running": state.eq_ignore_ascii_case("running"),
|
||||
"health": health,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn state_from_podman_status(status: &str) -> &str {
|
||||
if status.starts_with("Up ") {
|
||||
"running"
|
||||
} else if status.starts_with("Exited ") {
|
||||
"exited"
|
||||
} else if status.starts_with("Created") {
|
||||
"created"
|
||||
} else if status.starts_with("Stopping") {
|
||||
"stopping"
|
||||
} else if status.starts_with("Removing") {
|
||||
"removing"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_health_from_status(status: &str) -> Option<&str> {
|
||||
let start = status.rfind('(')?;
|
||||
let end = status.rfind(')')?;
|
||||
(start < end).then(|| &status[start + 1..end])
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
|
||||
if health != "none" {
|
||||
return Some(health.to_string());
|
||||
}
|
||||
}
|
||||
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
|
||||
"running" => Some("healthy".to_string()),
|
||||
"created" => Some("starting".to_string()),
|
||||
"paused" => Some("paused".to_string()),
|
||||
"stopping" => Some("unhealthy".to_string()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,7 @@ impl RpcHandler {
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
@@ -287,10 +286,13 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
|
||||
debug!(
|
||||
"Browsing peer content at {} (fips={})",
|
||||
onion,
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
@@ -348,8 +350,7 @@ impl RpcHandler {
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
@@ -407,11 +408,15 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}/preview", content_id);
|
||||
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
|
||||
debug!(
|
||||
"Fetching content preview from {}{} (fips={})",
|
||||
onion,
|
||||
path,
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Route an RPC method name to its handler, returning the result value.
|
||||
pub(super) async fn dispatch(
|
||||
&self,
|
||||
self: &Arc<Self>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
@@ -12,6 +13,7 @@ impl RpcHandler {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
@@ -36,19 +38,24 @@ impl RpcHandler {
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
"container-stop" => self.handle_container_stop(params).await,
|
||||
"container-restart" => self.handle_container_restart(params).await,
|
||||
"container-remove" => self.handle_container_remove(params).await,
|
||||
"container-list" => self.handle_container_list().await,
|
||||
"container-status" => self.handle_container_status(params).await,
|
||||
"container-logs" => self.handle_container_logs(params).await,
|
||||
"container-health" => self.handle_container_health(params).await,
|
||||
|
||||
// Package management (for docker-compose apps)
|
||||
"package.install" => self.handle_package_install(params).await,
|
||||
// Package management (for docker-compose apps).
|
||||
// install/uninstall/update return immediately with a
|
||||
// transitional status; the actual work runs in a background
|
||||
// tokio::spawn so the HTTP request doesn't block for minutes.
|
||||
"package.install" => self.clone().spawn_package_install(params).await,
|
||||
"package.start" => self.handle_package_start(params).await,
|
||||
"package.stop" => self.handle_package_stop(params).await,
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.handle_package_uninstall(params).await,
|
||||
"package.update" => self.handle_package_update(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)
|
||||
@@ -91,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
|
||||
}
|
||||
@@ -220,6 +241,7 @@ impl RpcHandler {
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
"registry.remove" => self.handle_registry_remove(params).await,
|
||||
"registry.set-primary" => self.handle_registry_set_primary(params).await,
|
||||
"registry.test" => self.handle_registry_test(params).await,
|
||||
|
||||
// Streaming ecash payments
|
||||
@@ -414,6 +436,16 @@ impl RpcHandler {
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
"fips.restart" => self.handle_fips_restart().await,
|
||||
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||
"fips.add-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_add_seed_anchor(&p).await
|
||||
}
|
||||
"fips.remove-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_remove_seed_anchor(&p).await
|
||||
}
|
||||
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
@@ -421,6 +453,23 @@ impl RpcHandler {
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.list-mirrors" => self.handle_update_list_mirrors().await,
|
||||
"update.add-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_add_mirror(&p).await
|
||||
}
|
||||
"update.remove-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_remove_mirror(&p).await
|
||||
}
|
||||
"update.set-primary-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_primary_mirror(&p).await
|
||||
}
|
||||
"update.test-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
@@ -497,6 +546,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
|
||||
@@ -403,7 +403,10 @@ impl RpcHandler {
|
||||
});
|
||||
let own_fips_npub = match own_fips_npub {
|
||||
Some(n) => Some(n),
|
||||
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
|
||||
None => crate::fips::service::read_upstream_npub()
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
};
|
||||
|
||||
let state = federation::build_local_state(
|
||||
@@ -461,8 +464,7 @@ impl RpcHandler {
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
// forever. Drop it quietly — no useful recovery path.
|
||||
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||
let own_did_result =
|
||||
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_onion_trim = own_data
|
||||
.server_info
|
||||
.tor_address
|
||||
@@ -568,11 +570,7 @@ impl RpcHandler {
|
||||
let new_peer_did = did.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(
|
||||
&data_dir,
|
||||
&new_peer_did,
|
||||
)
|
||||
.await
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
|
||||
{
|
||||
tracing::debug!(
|
||||
peer_did = %new_peer_did,
|
||||
|
||||
@@ -12,8 +12,7 @@ use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
@@ -36,13 +35,19 @@ impl RpcHandler {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
fips::config::install(&identity_dir).await?;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Restart whichever fips unit is supervising the daemon on this host.
|
||||
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
|
||||
/// nodes that had the upstream debian package set up first may only have
|
||||
/// `fips.service`. We resolve the active one via `service::active_unit()`
|
||||
/// so the UI button is never a no-op.
|
||||
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
|
||||
fips::service::restart(fips::SERVICE_UNIT).await?;
|
||||
Ok(serde_json::json!({ "restarted": true }))
|
||||
let unit = fips::service::active_unit().await;
|
||||
fips::service::restart(unit).await?;
|
||||
Ok(serde_json::json!({ "restarted": true, "unit": unit }))
|
||||
}
|
||||
|
||||
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
|
||||
@@ -53,7 +58,7 @@ impl RpcHandler {
|
||||
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
|
||||
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let before = fips::FipsStatus::query(&identity_dir).await;
|
||||
let before = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
|
||||
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
|
||||
// mismatch. The daemon silently authenticates with a garbage
|
||||
@@ -70,30 +75,37 @@ impl RpcHandler {
|
||||
let _ = fips::config::install(&identity_dir).await;
|
||||
}
|
||||
|
||||
// Clean stop+start rather than `restart`, so a daemon that
|
||||
// fails to come back up surfaces as service_active=false
|
||||
// instead of quietly sticking with the old process.
|
||||
let _ = fips::service::stop(fips::SERVICE_UNIT).await;
|
||||
// Operate on whichever fips unit is actually up — nodes that
|
||||
// have the upstream `fips.service` rather than the
|
||||
// archipelago-managed `archipelago-fips.service` used to see
|
||||
// Reconnect silently fail because we stopped a unit that
|
||||
// didn't exist. Clean stop+start rather than `restart` so a
|
||||
// daemon that fails to come back up surfaces as
|
||||
// service_active=false instead of quietly sticking with the
|
||||
// old process.
|
||||
let unit = fips::service::active_unit().await;
|
||||
let _ = fips::service::stop(unit).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
fips::service::activate(unit).await?;
|
||||
|
||||
// Re-push seed anchors after restart so freshly-bound daemons
|
||||
// don't have to wait 5 min for the periodic apply loop.
|
||||
if let Ok(list) = fips::anchors::load(&self.config.data_dir).await {
|
||||
if !list.is_empty() {
|
||||
let _ = fips::anchors::apply(&list).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
let after = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&identity_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected || std::time::Instant::now() >= deadline {
|
||||
break s;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
};
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
@@ -111,13 +123,13 @@ impl RpcHandler {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
let hint = match likely_cause {
|
||||
"connected" => "Anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check archipelago-fips.service.",
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or the anchor (fips.v0l.io) could be down.",
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but the anchor hasn't been seen yet. Give it a minute and re-check.",
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
@@ -129,4 +141,65 @@ impl RpcHandler {
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the seed-anchor entries configured on this node.
|
||||
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Add (or update) a seed anchor and immediately push it into the
|
||||
/// running daemon. Params: `{ npub, address, transport?, label? }`.
|
||||
pub(super) async fn handle_fips_add_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
|
||||
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
|
||||
if !anchor.npub.starts_with("npub1") {
|
||||
anyhow::bail!("npub must be bech32 (npub1...)");
|
||||
}
|
||||
if !anchor.address.contains(':') {
|
||||
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||
}
|
||||
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
// Push just the newly-added anchor into the running daemon so
|
||||
// the user sees effect without waiting for the periodic apply.
|
||||
let results = fips::anchors::apply(&[anchor]).await;
|
||||
Ok(serde_json::json!({
|
||||
"seed_anchors": list,
|
||||
"apply": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
|
||||
/// down an already-authenticated peer connection — it only stops
|
||||
/// us from re-dialing the anchor on the next apply cycle.
|
||||
pub(super) async fn handle_fips_remove_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let npub = params
|
||||
.get("npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
|
||||
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Re-apply all seed anchors to the running daemon. Useful after a
|
||||
/// FIPS restart or when the user wants to force a reconnection
|
||||
/// attempt without waiting for the periodic apply loop.
|
||||
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
let results = fips::anchors::apply(&list).await;
|
||||
Ok(serde_json::json!({
|
||||
"applied": results.len(),
|
||||
"results": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,24 +742,25 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
let relay_urls: Vec<String> =
|
||||
if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
@@ -62,7 +64,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/channels")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -72,7 +74,7 @@ impl RpcHandler {
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/channels/pending")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -211,7 +213,7 @@ impl RpcHandler {
|
||||
"perm": true
|
||||
});
|
||||
let _ = client
|
||||
.post("https://127.0.0.1:8080/v1/peers")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.send()
|
||||
@@ -224,7 +226,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
@@ -291,7 +293,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{LndAmount, LndBalanceResponse};
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
@@ -34,11 +34,7 @@ struct LndChannelBalanceResponse {
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -48,7 +44,7 @@ impl RpcHandler {
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -58,7 +54,7 @@ impl RpcHandler {
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/channels")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -74,7 +70,7 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -114,7 +110,6 @@ impl RpcHandler {
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
@@ -130,9 +125,7 @@ impl RpcHandler {
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary -> base64url)
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
@@ -173,7 +166,7 @@ impl RpcHandler {
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 8080,
|
||||
"rest_port": 18080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
@@ -183,10 +176,7 @@ impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -196,7 +186,7 @@ impl RpcHandler {
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/channels/backup")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -4,7 +4,12 @@ mod payments;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Canonical on-host path for LND's admin macaroon.
|
||||
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
|
||||
// Shared LND response types used by multiple submodules
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -17,15 +22,45 @@ pub(super) struct LndAmount {
|
||||
pub sat: Option<String>,
|
||||
}
|
||||
|
||||
/// Read LND's admin macaroon from disk.
|
||||
///
|
||||
/// The macaroon lives inside LND's container data dir and is owned by a
|
||||
/// rootless-podman subordinate UID (typically 100000), mode 640. The
|
||||
/// archipelago server runs as UID 1000 and therefore cannot read it
|
||||
/// directly. We first try a plain read (works if an operator has relaxed
|
||||
/// permissions), then fall back to `sudo cat` — mirroring the pattern
|
||||
/// already used for Tor hidden-service hostnames.
|
||||
pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
match tokio::fs::read(LND_ADMIN_MACAROON_PATH).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["-n", "cat", LND_ADMIN_MACAROON_PATH])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read LND admin macaroon (direct: {direct_err}); sudo fallback also failed"
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Failed to read LND admin macaroon — is LND installed? (direct: {direct_err}; sudo: {})",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
/// hex-encoded admin macaroon for request headers.
|
||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
|
||||
@@ -2,6 +2,8 @@ use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
@@ -35,7 +37,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
@@ -91,7 +93,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -4,27 +4,49 @@ use base64::Engine;
|
||||
use tracing::info;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/newaddress")
|
||||
.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 }))
|
||||
@@ -69,7 +91,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/transactions")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&send_body)
|
||||
.send()
|
||||
@@ -129,7 +151,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/invoices")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
@@ -231,7 +253,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
@@ -289,7 +311,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
@@ -322,7 +344,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let pub_resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/tx"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&publish_body)
|
||||
.send()
|
||||
@@ -387,7 +409,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
@@ -416,7 +438,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
@@ -514,7 +536,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/initwallet")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/initwallet"))
|
||||
.json(&init_body)
|
||||
.send()
|
||||
.await
|
||||
@@ -541,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ impl RpcHandler {
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
|
||||
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
|
||||
let paid = match client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
|
||||
@@ -761,7 +761,9 @@ impl RpcHandler {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
|
||||
|
||||
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
|
||||
let meta = blob_store
|
||||
.put(&bytes, &mime, filename_hint, None, false)
|
||||
.await?;
|
||||
if meta.cid != cid {
|
||||
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"Not supported",
|
||||
"Requires",
|
||||
"requires",
|
||||
"must be",
|
||||
"cannot",
|
||||
|
||||
@@ -2,6 +2,7 @@ mod analytics;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
pub(crate) mod bitcoin_relay;
|
||||
mod container;
|
||||
mod content;
|
||||
mod credentials;
|
||||
@@ -12,7 +13,7 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod lnd;
|
||||
pub(in crate::api) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
@@ -31,6 +32,7 @@ mod streaming;
|
||||
mod system;
|
||||
mod tor;
|
||||
mod totp;
|
||||
mod transitional;
|
||||
mod transport;
|
||||
mod update;
|
||||
mod vpn;
|
||||
@@ -39,7 +41,7 @@ mod webhooks;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
|
||||
@@ -62,7 +64,14 @@ pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
pub struct RpcHandler {
|
||||
config: Config,
|
||||
auth_manager: AuthManager,
|
||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
/// Shared lifecycle orchestrator (Dev or Prod). Always `Some` in a normal
|
||||
/// build — the only reason it is `Option` is so tests that don't exercise
|
||||
/// container RPCs can skip constructing one.
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
/// Concrete handle to the dev orchestrator, when we're in dev mode. Used by
|
||||
/// `container-install { manifest_path }` which takes an ad-hoc manifest
|
||||
/// path and is not part of the shared trait.
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
state_manager: Arc<StateManager>,
|
||||
pub(crate) metrics_store: Arc<MetricsStore>,
|
||||
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
|
||||
@@ -79,6 +88,15 @@ pub struct RpcHandler {
|
||||
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
|
||||
/// and by ContentRef receivers to request caps scoped to themselves.
|
||||
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
/// Kick the package scanner to run immediately (bypassing the 60s interval).
|
||||
/// Used by install/update success paths so the fresh manifest (with populated
|
||||
/// `interfaces.main.ui`) lands before we flip state to Running — closes the
|
||||
/// "Launch button is missing for up to 60s after install" UX gap.
|
||||
pub(crate) scan_kick: Arc<tokio::sync::Notify>,
|
||||
/// Monotonic counter incremented by the scan loop after each completed scan.
|
||||
/// Install/update success paths subscribe to this to know when a kicked scan
|
||||
/// has actually finished before flipping to the terminal state.
|
||||
pub(crate) scan_tick: Arc<tokio::sync::watch::Sender<u64>>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -87,15 +105,10 @@ impl RpcHandler {
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||
let orchestrator = if config.dev_mode {
|
||||
Some(Arc::new(
|
||||
DevContainerOrchestrator::new(config.clone()).await?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let port_allocator = Arc::new(tokio::sync::Mutex::new(
|
||||
PortAllocator::new(&config.data_dir).await?,
|
||||
));
|
||||
@@ -129,6 +142,7 @@ impl RpcHandler {
|
||||
config,
|
||||
auth_manager,
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
port_allocator,
|
||||
@@ -140,6 +154,8 @@ impl RpcHandler {
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
scan_kick: Arc::new(tokio::sync::Notify::new()),
|
||||
scan_tick: Arc::new(tokio::sync::watch::channel(0u64).0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,6 +196,21 @@ impl RpcHandler {
|
||||
Arc::clone(&self.mesh_service)
|
||||
}
|
||||
|
||||
/// Shared Notify handle the package-scanner loop waits on (in addition to
|
||||
/// its periodic tick). Install/update success paths call `notify_one()` to
|
||||
/// trigger an immediate scan so the fresh manifest lands before we flip to
|
||||
/// the terminal Running state.
|
||||
pub fn scan_kick(&self) -> Arc<tokio::sync::Notify> {
|
||||
Arc::clone(&self.scan_kick)
|
||||
}
|
||||
|
||||
/// Sender half of the scan-completion watch channel. The scanner bumps this
|
||||
/// counter after every finished scan; install/update wait for an advance
|
||||
/// after kicking so they know the fresh manifest has landed.
|
||||
pub fn scan_tick(&self) -> Arc<tokio::sync::watch::Sender<u64>> {
|
||||
Arc::clone(&self.scan_tick)
|
||||
}
|
||||
|
||||
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
|
||||
// Only set Secure flag when the original request was over HTTPS.
|
||||
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
|
||||
@@ -197,7 +228,10 @@ impl RpcHandler {
|
||||
""
|
||||
}
|
||||
|
||||
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
pub async fn handle(
|
||||
self: Arc<Self>,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract session cookie before consuming the request
|
||||
let (parts, body) = req.into_parts();
|
||||
let session_token = session::extract_session_cookie(&parts.headers);
|
||||
@@ -269,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"
|
||||
@@ -376,7 +411,7 @@ impl RpcHandler {
|
||||
|
||||
// Route to handler (track latency for metrics)
|
||||
let rpc_start = std::time::Instant::now();
|
||||
let result = self.dispatch(&rpc_req.method, params, &session_token).await;
|
||||
let result = Self::dispatch(&self, &rpc_req.method, params, &session_token).await;
|
||||
|
||||
// Record RPC latency for monitoring
|
||||
let elapsed_ms = rpc_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Async wrappers for `package.install`, `package.uninstall`, `package.update`.
|
||||
//!
|
||||
//! The inner `handle_package_*` functions are large (install is 480 lines with
|
||||
//! the stack dispatchers, update is 300, uninstall is 200) and do their own
|
||||
//! fine-grained progress tracking via `install_progress` and `uninstall_stage`.
|
||||
//! We wrap them rather than refactor them.
|
||||
//!
|
||||
//! Each wrapper:
|
||||
//! 1. Parses + validates the RPC params (cheap, synchronous). Errors here
|
||||
//! return immediately to the caller before any state change.
|
||||
//! 2. Flips the package state to the transitional variant
|
||||
//! (`Installing` / `Removing` / `Updating`) so the UI sees it on the
|
||||
//! next WebSocket push (before the RPC response even lands).
|
||||
//! 3. `tokio::spawn`s a background task that invokes the existing
|
||||
//! `handle_package_*` method on the Arc-held self.
|
||||
//! 4. On task success: no state change needed — the inner handler has
|
||||
//! already written the terminal state (Running for install/update, or
|
||||
//! removed the entry for uninstall).
|
||||
//! 5. On task failure: revert state to the pre-transition value (or delete
|
||||
//! the entry for install, since there was no pre-state), write a line
|
||||
//! to the persistent install log, and clear any stale progress fields.
|
||||
//! 6. Returns `{ "status": "installing" }` etc. immediately.
|
||||
//!
|
||||
//! The server package-scan loop's `merge_preserving_transitional` helper
|
||||
//! already knows to preserve `Installing` / `Removing` / `Updating` between
|
||||
//! scans, so live progress updates broadcast from inside the spawned task
|
||||
//! reach the UI correctly.
|
||||
|
||||
use super::install::install_log;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Async wrapper for `package.install`. Returns `{ "status": "installing" }`
|
||||
/// immediately after flipping state to `Installing` and spawning the
|
||||
/// actual install pipeline. On failure, removes the package entry from
|
||||
/// state so the UI reverts to "not installed".
|
||||
pub(in crate::api::rpc) async fn spawn_package_install(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Extract + validate package_id synchronously so bad params fail
|
||||
// fast without touching state.
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flip state to Installing BEFORE the spawn so the first WebSocket
|
||||
// push carries the transitional state. Uses the same
|
||||
// `create_installing_entry` path the inner handler would use once
|
||||
// it starts pulling, so the UI sees a consistent shape.
|
||||
flip_to_installing(&self.state_manager, &package_id).await;
|
||||
|
||||
install_log(&format!("INSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_install(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.install {}: complete", package_id_spawn);
|
||||
// The install pipeline has verified the container is up
|
||||
// and healthy (see install.rs post-start exit check).
|
||||
// Kick the scanner first so the fresh manifest (with
|
||||
// `interfaces.main.ui` from the live port binding) lands
|
||||
// BEFORE we flip to Running — without this the Launch
|
||||
// button is missing for up to 60s after a successful
|
||||
// install, because the skeletal install-time manifest
|
||||
// has `interfaces: None`.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
// We MUST explicitly transition out of Installing here:
|
||||
// `merge_preserving_transitional` in the package-scan
|
||||
// loop treats Installing as RPC-owned and refuses to
|
||||
// let the scanner overwrite it with the observed
|
||||
// Running state. Without this write, the entry stays
|
||||
// stuck at Installing forever.
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
handler.clear_install_progress(&package_id_spawn).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Don't remove the entry — that's what made the card
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
// the entry visible with state=Stopped + the install
|
||||
// error in install_progress.message so the user can see
|
||||
// what went wrong and decide whether to retry or
|
||||
// uninstall. clear_install_progress would erase the
|
||||
// message, so we set it explicitly here instead.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "installing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.uninstall`. Returns `{ "status": "removing" }`
|
||||
/// immediately. State stays `Removing` until the inner handler finishes
|
||||
/// (including the `sudo rm -rf` of app data, which can take minutes for
|
||||
/// bitcoin-core's chainstate). On failure, reverts to the pre-transition
|
||||
/// state (usually Running or Stopped) so the user can retry.
|
||||
pub(in crate::api::rpc) async fn spawn_package_uninstall(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Removing).await;
|
||||
|
||||
install_log(&format!("UNINSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_uninstall(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.uninstall {}: complete", package_id_spawn);
|
||||
// Inner handler already removed the package entry on
|
||||
// success. Nothing more to do here.
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UNINSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Revert to pre-transition state so the user can retry.
|
||||
// Also clear any stale uninstall_stage label.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state_and_clear_uninstall_stage(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
prev,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "removing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.update`. Returns `{ "status": "updating" }`
|
||||
/// immediately. The inner handler already manages its own rollback on
|
||||
/// failure (restarts old containers); this wrapper just flips state and
|
||||
/// spawns.
|
||||
pub(in crate::api::rpc) async fn spawn_package_update(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The inner handler flips state to Updating itself, but we do it
|
||||
// here too so the transitional state lands before the spawn yields.
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Updating).await;
|
||||
|
||||
install_log(&format!("UPDATE SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
// terminal Running state ourselves or the entry will stay
|
||||
// stuck at Updating forever. The update pipeline has
|
||||
// already verified the new container is running via its
|
||||
// post-recreate check.
|
||||
// Kick the scanner first so any manifest changes from the
|
||||
// new image version (interfaces, ports, etc.) land before
|
||||
// we flip to Running.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.update {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Inner handler already ran rollback_update + cleared
|
||||
// update state, but be defensive: revert to pre-state
|
||||
// in case the inner flow died before its cleanup.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "updating",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State-manager helpers (free fns, usable from inside spawned tasks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create or update the entry for this package with `Installing` state.
|
||||
/// Matches what the inner handler's `set_install_progress` would do on first
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.state = PackageState::Installing;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
/// no entry existed). Used for revert-on-failure.
|
||||
async fn flip_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// Set state unconditionally (no-op if entry no longer exists).
|
||||
async fn set_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||
/// fails and we revert — the user doesn't want a stale "Removing app data"
|
||||
/// message sitting on a Running entry.
|
||||
async fn set_package_state_and_clear_uninstall_stage(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
/// (with a 2s timeout). Used by install/update success paths so the fresh
|
||||
/// manifest — with `interfaces.main.ui` populated from the now-running
|
||||
/// container's port binding — lands BEFORE we flip state to Running.
|
||||
///
|
||||
/// Without this, the frontend sees `state = running` but the skeletal
|
||||
/// install-time manifest (interfaces = None), and hides the Launch button
|
||||
/// for up to the full 60s scan interval.
|
||||
///
|
||||
/// The scan merges via `merge_preserving_transitional`, which keeps
|
||||
/// state = Installing (we haven't flipped yet) while taking the fresh
|
||||
/// manifest. After this returns, the caller writes Running on top of the
|
||||
/// now-populated manifest.
|
||||
async fn kick_scanner_and_wait(handler: &RpcHandler) {
|
||||
let mut rx = handler.scan_tick.subscribe();
|
||||
let start = *rx.borrow_and_update();
|
||||
handler.scan_kick.notify_one();
|
||||
// 2s is well above a typical podman scan (~200ms on .228, ~500ms worst
|
||||
// case). If it times out we proceed anyway — the next 60s scan will
|
||||
// self-heal and the worst case is the pre-fix behavior (Launch button
|
||||
// appears a bit late).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while *rx.borrow_and_update() == start {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1,6 +1,96 @@
|
||||
use super::validation::validate_app_id;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "bitcoin-knots"
|
||||
| "bitcoin-ui"
|
||||
| "lnd"
|
||||
| "lnd-ui"
|
||||
| "electrumx"
|
||||
| "electrs"
|
||||
| "mempool-electrs"
|
||||
| "electrs-ui"
|
||||
| "mempool"
|
||||
| "mempool-web"
|
||||
| "mempool-api"
|
||||
| "archy-mempool-db"
|
||||
| "archy-mempool-web"
|
||||
| "btcpay"
|
||||
| "btcpay-server"
|
||||
| "btcpayserver"
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
)
|
||||
}
|
||||
|
||||
fn safe_dynamic_arg(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 512
|
||||
&& !value.chars().any(|c| matches!(c, '\0' | '\n' | '\r'))
|
||||
}
|
||||
|
||||
async fn dynamic_app_config(
|
||||
app_id: &str,
|
||||
) -> Option<(
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
)> {
|
||||
if is_platform_managed_app(app_id) {
|
||||
return None;
|
||||
}
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let data = tokio::fs::read_to_string(&config_path).await.ok()?;
|
||||
let cfg = serde_json::from_str::<serde_json::Value>(&data).ok()?;
|
||||
let string_array = |key: &str| -> Vec<String> {
|
||||
cfg.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let command = cfg
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from);
|
||||
let args = cfg.get("args").and_then(|v| v.as_array()).map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
tracing::info!(app_id = %app_id, "loaded catalog runtime config for generic app");
|
||||
Some((
|
||||
string_array("ports"),
|
||||
string_array("volumes"),
|
||||
string_array("env"),
|
||||
command,
|
||||
args.filter(|a| !a.is_empty()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||
#[allow(dead_code)]
|
||||
@@ -9,7 +99,7 @@ pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
"ghcr.io/",
|
||||
"localhost/",
|
||||
"git.tx1138.com/",
|
||||
"23.182.128.160:3000/",
|
||||
"146.59.87.168:3000/",
|
||||
];
|
||||
|
||||
/// Validate Docker image against trusted registry allowlist.
|
||||
@@ -29,7 +119,7 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||
};
|
||||
matches!(
|
||||
registry,
|
||||
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "23.182.128.160:3000"
|
||||
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,26 +153,39 @@ 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 and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
|
||||
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(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
]
|
||||
}
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
|
||||
// dropping it removes a class of intra-pod spoofing capability.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 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(),
|
||||
],
|
||||
// LND additionally needs NET_RAW for TLS certificate generation
|
||||
// (netlink interface enumeration during `lnd --tlscertpath` first run).
|
||||
// Fedimint inherits the same set because the gateway also enumerates
|
||||
// network interfaces on startup.
|
||||
"lnd" | "fedimint" | "fedimint-gateway" => 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(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
],
|
||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||
"vaultwarden" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
@@ -114,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(),
|
||||
@@ -153,36 +256,35 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
/// Get container health check arguments for podman run.
|
||||
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
|
||||
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
|
||||
let btc_health =
|
||||
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
|
||||
let (cmd, interval, retries) = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||
// Bitcoin images do not consistently ship bitcoin-cli/curl/nc. Rely on
|
||||
// process state here; manifests still describe the desired TCP check.
|
||||
"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" => (
|
||||
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
|
||||
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
(http_probe_cmd("http://localhost:8080/"), "30s", "3")
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
|
||||
}
|
||||
"nextcloud" => (
|
||||
"curl -sf http://localhost:80/status.php || exit 1",
|
||||
"curl -s -o /dev/null http://localhost:80/status.php || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"homeassistant" | "home-assistant" => {
|
||||
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
|
||||
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
|
||||
}
|
||||
"grafana" => (
|
||||
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
@@ -194,7 +296,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
||||
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
||||
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
|
||||
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
||||
"botfights" => (
|
||||
"node -e \"fetch(\\\"http://127.0.0.1:9100/api/health\\\").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
|
||||
"photoprism" => (
|
||||
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
||||
"60s",
|
||||
@@ -205,23 +312,12 @@ 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" => (
|
||||
"curl -sf http://localhost:9000/api/status || 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"),
|
||||
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
|
||||
"nostr-rs-relay" | "nostr-relay" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
}
|
||||
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
|
||||
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
|
||||
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
|
||||
"routstr" => (
|
||||
"curl -sf http://localhost:8000/v1/models || exit 1",
|
||||
"30s",
|
||||
@@ -236,20 +332,37 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
format!("--health-cmd={}", cmd),
|
||||
format!("--health-interval={}", interval),
|
||||
format!("--health-retries={}", retries),
|
||||
"--health-timeout=10s".to_string(),
|
||||
"--health-start-period=60s".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn http_probe_cmd(url: &'static str) -> &'static str {
|
||||
match url {
|
||||
"http://localhost:8999/api/v1/backend-info" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8999/api/v1/backend-info; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8999/api/v1/backend-info; else exit 0; fi",
|
||||
"http://localhost:8080/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi",
|
||||
"http://localhost:81/api/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/api/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/api/; else exit 0; fi",
|
||||
"http://localhost:81/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/; else exit 0; fi",
|
||||
_ => "exit 0",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "4g",
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// 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 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
|
||||
"lnd" => "512m",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
@@ -266,16 +379,17 @@ 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
|
||||
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
|
||||
"immich_postgres" | "penpot-postgres" => "256m",
|
||||
"immich_postgres" => "2g",
|
||||
"penpot-postgres" => "256m",
|
||||
"immich_redis" | "penpot-valkey" => "128m",
|
||||
// Default
|
||||
_ => "512m",
|
||||
@@ -291,16 +405,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
let archy = format!("archy-{}", package_id);
|
||||
|
||||
match package_id {
|
||||
// Bitcoin: multiple historical names
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
// Bitcoin variants share the UI but not the backend process. Keep
|
||||
// backend names precise so stopping one implementation does not clear
|
||||
// stop markers or issue podman operations for the other.
|
||||
"bitcoin" | "bitcoin-knots" => vec![
|
||||
"bitcoin-knots".into(),
|
||||
"bitcoin".into(),
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-knots".into(),
|
||||
"archy-bitcoin".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
"bitcoin-core" => vec![
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-core".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
// LND + UI
|
||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||
// Electrumx: multiple aliases
|
||||
@@ -359,6 +480,20 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"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(),
|
||||
@@ -373,12 +508,14 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
|
||||
/// Find all running/stopped containers that belong to a given app.
|
||||
/// Uses the canonical name list from all_container_names().
|
||||
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
validate_app_id(package_id)?;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
|
||||
cmd.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.context("podman ps timed out while listing containers")?
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||
@@ -393,16 +530,47 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{all_container_names, get_health_check_args};
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
assert!(core.contains(&"bitcoin-core".to_string()));
|
||||
assert!(!core.contains(&"bitcoin-knots".to_string()));
|
||||
|
||||
let knots = all_container_names("bitcoin-knots");
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grafana_health_requires_writable_data_and_http_health() {
|
||||
let args = get_health_check_args("grafana", "unused");
|
||||
let health_cmd = args
|
||||
.iter()
|
||||
.find_map(|arg| arg.strip_prefix("--health-cmd="))
|
||||
.expect("grafana should have a health command");
|
||||
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana"));
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
|
||||
assert!(health_cmd.contains("http://localhost:3000/api/health"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
format!("{}/electrumx", base),
|
||||
format!("{}/mempool-electrs", base),
|
||||
],
|
||||
"fedimint" => vec![
|
||||
format!("{}/fedimint", base),
|
||||
@@ -414,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)],
|
||||
}
|
||||
}
|
||||
@@ -475,6 +644,10 @@ pub(super) async fn get_app_config(
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
) {
|
||||
if let Some(config) = dynamic_app_config(app_id).await {
|
||||
return config;
|
||||
}
|
||||
|
||||
match app_id {
|
||||
"homeassistant" | "home-assistant" => (
|
||||
vec!["8123:8123".to_string()],
|
||||
@@ -483,7 +656,43 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
||||
"bitcoin-core" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
// Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads
|
||||
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
|
||||
// carries rpcauth; we inject the networking flags as CLI args so
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
// -maxconnections=125 — default but explicit, so ops can
|
||||
// tune downward on bandwidth-constrained nodes.
|
||||
Some(vec![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
@@ -499,7 +708,7 @@ pub(super) async fn get_app_config(
|
||||
vec![
|
||||
"9735:9735".to_string(),
|
||||
"10009:10009".to_string(),
|
||||
"8080:8080".to_string(),
|
||||
"18080:8080".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||
vec![],
|
||||
@@ -510,9 +719,7 @@ pub(super) async fn get_app_config(
|
||||
"--bitcoin.node=bitcoind".to_string(),
|
||||
format!("--bitcoind.rpcuser={}", rpc_user),
|
||||
format!("--bitcoind.rpcpass={}", rpc_pass),
|
||||
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
|
||||
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
|
||||
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
|
||||
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
|
||||
"--rpclisten=0.0.0.0:10009".to_string(),
|
||||
"--restlisten=0.0.0.0:8080".to_string(),
|
||||
"--listen=0.0.0.0:9735".to_string(),
|
||||
@@ -526,7 +733,8 @@ pub(super) async fn get_app_config(
|
||||
"BTCPAY_PROTOCOL=http".to_string(),
|
||||
format!("BTCPAY_HOST={}:23000", host_ip),
|
||||
"BTCPAY_CHAINS=btc".to_string(),
|
||||
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
||||
"BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838".to_string(),
|
||||
"BTCPAY_BTCRPCURL=http://bitcoin-knots:8332".to_string(),
|
||||
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
|
||||
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
|
||||
format!("BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
|
||||
@@ -538,7 +746,7 @@ pub(super) async fn get_app_config(
|
||||
"mempool" | "mempool-web" => (
|
||||
vec!["4080:8080".to_string()],
|
||||
vec![],
|
||||
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
||||
vec!["BACKEND_MAINNET_HTTP_HOST=mempool-api".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
@@ -547,12 +755,12 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=host.containers.internal".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
format!("CORE_RPC_USERNAME={}", rpc_user),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
@@ -569,12 +777,18 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||
vec![
|
||||
format!(
|
||||
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
|
||||
"DAEMON_URL=http://{}:{}@bitcoin-knots:8332/",
|
||||
rpc_user, rpc_pass
|
||||
),
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".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(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
@@ -587,7 +801,7 @@ pub(super) async fn get_app_config(
|
||||
"MYSQL_DATABASE=mempool".to_string(),
|
||||
"MYSQL_USER=mempool".to_string(),
|
||||
format!("MYSQL_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mempool-db-root-password", "rootpass")),
|
||||
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mysql-root-db-password", "rootpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
@@ -711,45 +925,71 @@ pub(super) async fn get_app_config(
|
||||
]),
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
vec![
|
||||
"81:81".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"8443: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(),
|
||||
"/var/run/podman/podman.sock:/var/run/docker.sock".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,
|
||||
None,
|
||||
),
|
||||
"uptime-kuma" => (
|
||||
vec!["3001:3001".to_string()],
|
||||
vec!["3002:3001".to_string()],
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--".to_string(),
|
||||
"node".to_string(),
|
||||
"server/server.js".to_string(),
|
||||
]),
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()],
|
||||
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
|
||||
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
|
||||
Some(
|
||||
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
|
||||
),
|
||||
// Don't use custom_command (Option<String>) — install.rs passes
|
||||
// it as a SINGLE arg to podman, which then treats the whole
|
||||
// "sh -c 'tailscale web …'" string as the executable name and
|
||||
// fails: "executable file `sh -c 'tailscale web …'` not found".
|
||||
// custom_args (Option<Vec<String>>) splits properly.
|
||||
None,
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".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" => (
|
||||
vec![
|
||||
@@ -768,13 +1008,13 @@ pub(super) async fn get_app_config(
|
||||
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
||||
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
||||
format!("FM_API_URL=ws://{}:8174", host_ip),
|
||||
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||
"FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(vec![
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
|
||||
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
|
||||
]),
|
||||
),
|
||||
"fedimint-gateway" => {
|
||||
@@ -798,7 +1038,7 @@ pub(super) async fn get_app_config(
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"http://bitcoin-knots:8332".to_string(),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
@@ -881,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;
|
||||
(
|
||||
@@ -909,8 +1137,8 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
|
||||
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
|
||||
// Gitea listens on container port 3000 and is launched directly on
|
||||
// host port 3001 because it blocks iframe embedding.
|
||||
"gitea" => (
|
||||
vec!["3001:3000".to_string(), "2222:22".to_string()],
|
||||
vec![
|
||||
@@ -931,31 +1159,11 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
),
|
||||
_ => {
|
||||
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
|
||||
// This allows dynamic apps from the remote catalog to be installed
|
||||
// without hardcoding their config here.
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||
let ports = cfg.get("ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let volumes = cfg.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let env_vars = cfg.get("env")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
tracing::info!("Loaded dynamic config for app: {}", app_id);
|
||||
return (ports, volumes, env_vars, None, None);
|
||||
}
|
||||
}
|
||||
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
|
||||
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
|
||||
// No catalog runtime metadata found; use minimal defaults
|
||||
// (container's own EXPOSE/VOLUME). New generic apps should declare
|
||||
// containerConfig in the registry catalog instead of adding Rust cases.
|
||||
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
|
||||
(vec![], vec![], vec![], None, None)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use crate::data_model::{PackageDataEntry, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
@@ -7,6 +9,20 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
|
||||
/// Names of container variants that represent a running Electrum indexer
|
||||
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
|
||||
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
|
||||
)
|
||||
}
|
||||
|
||||
fn archival_bitcoin_required_message(package_id: &str) -> String {
|
||||
format!(
|
||||
"Requires an archival Bitcoin node while indexing: {package_id}. This node is running pruned Bitcoin because it does not have enough disk for full block history. Add enough storage for an archival node (about 1 TB or more), resync Bitcoin without pruning/with txindex, then install {package_id}."
|
||||
)
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
@@ -15,13 +31,43 @@ pub(super) struct RunningDeps {
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
pub(super) fn detect_running_deps_from_package_data(
|
||||
packages: &HashMap<String, PackageDataEntry>,
|
||||
) -> RunningDeps {
|
||||
let is_running = |names: &[&str]| {
|
||||
names.iter().any(|name| {
|
||||
packages
|
||||
.get(*name)
|
||||
.map(|pkg| pkg.state == PackageState::Running)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
|
||||
RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
let dep_check = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
if !dep_check.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to check running containers: {}",
|
||||
String::from_utf8_lossy(&dep_check.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
@@ -76,6 +122,107 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
|
||||
if !requires_unpruned_bitcoin(package_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "package-install-prune-check",
|
||||
"method": "getblockchaininfo",
|
||||
"params": [],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building Bitcoin RPC client")?;
|
||||
|
||||
let mut last_error = None;
|
||||
for _ in 0..3 {
|
||||
match client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) if status.is_success() => {
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
last_error = Some(format!(
|
||||
"Bitcoin RPC error while checking pruning status: {error}"
|
||||
));
|
||||
} else {
|
||||
return check_blockchain_info_for_pruning(package_id, &json);
|
||||
}
|
||||
}
|
||||
Ok(json) => {
|
||||
last_error = Some(format!(
|
||||
"Bitcoin RPC returned {status} while checking pruning status: {json}"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("decode Bitcoin RPC response: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("checking Bitcoin pruning status: {e}"));
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
|
||||
anyhow::bail!(archival_bitcoin_required_message(package_id));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Bitcoin RPC unavailable while checking pruning status: {}",
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value) -> Result<()> {
|
||||
let Some(result) = json.get("result") else {
|
||||
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
|
||||
};
|
||||
if result
|
||||
.get("pruned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
anyhow::bail!(archival_bitcoin_required_message(package_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_disk_gb() -> u64 {
|
||||
let output = std::process::Command::new("df")
|
||||
.args(["-BG", "/var/lib/archipelago"])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return u64::MAX;
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.nth(1)
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|size| size.trim_end_matches('G').parse::<u64>().ok())
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
@@ -129,6 +276,19 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -144,17 +304,23 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
return Ok(vec![format!("archy-{}", package_id)]);
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||
if order.is_empty() && containers.is_empty() {
|
||||
return Ok(vec![package_id.to_string()]);
|
||||
}
|
||||
let mut sorted = containers;
|
||||
for required in order {
|
||||
if !sorted.iter().any(|name| name == required) {
|
||||
sorted.push((*required).to_string());
|
||||
}
|
||||
}
|
||||
// If no special order is defined, fall back to mempool order for legacy
|
||||
// multi-container names that may still be returned by config lookups.
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
startup_order("mempool")
|
||||
} else {
|
||||
order
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
|
||||
Ok(sorted)
|
||||
}
|
||||
@@ -211,3 +377,40 @@ pub(super) fn configure_fedimint_lnd(
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{requires_unpruned_bitcoin, startup_order};
|
||||
|
||||
#[test]
|
||||
fn btcpay_start_order_includes_required_stack_members() {
|
||||
assert_eq!(
|
||||
startup_order("btcpay-server"),
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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 [
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"electrs",
|
||||
"mempool",
|
||||
"mempool-web",
|
||||
] {
|
||||
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
|
||||
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
mod async_lifecycle;
|
||||
mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
@@ -8,5 +9,6 @@ mod stacks;
|
||||
mod update;
|
||||
mod validation;
|
||||
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
|
||||
pub(in crate::api::rpc) use install::install_log;
|
||||
pub(super) use validation::validate_app_id;
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
|
||||
StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
///
|
||||
/// Prefer `set_install_phase` — this byte-counter API is kept for
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
@@ -15,7 +20,44 @@ impl RpcHandler {
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the install pipeline phase and broadcast. This is the
|
||||
/// primary progress signal — the UI maps each phase to a
|
||||
/// percentage and a user-facing label. Byte counters are retained
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
@@ -28,6 +70,18 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
@@ -40,9 +94,12 @@ impl RpcHandler {
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
@@ -57,7 +114,11 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
|
||||
// `.svg` or `.webp` assets and produces a broken-image flicker.
|
||||
// The frontend's `icon` computed falls through to the curated
|
||||
// map which has correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
@@ -81,6 +142,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
//! Per-app manual update handler.
|
||||
//!
|
||||
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
|
||||
//! remove old container(s) → recreate via reconcile script → verify running.
|
||||
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → verify running.
|
||||
//! Data volumes are preserved (bind mounts, not stored in container).
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
@@ -11,11 +11,13 @@ use super::runtime::stop_timeout_secs;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::image_versions;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::data_model::{InstallPhase, PackageState};
|
||||
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.
|
||||
@@ -34,15 +36,10 @@ impl RpcHandler {
|
||||
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
|
||||
// Reject if already updating
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(package_id) {
|
||||
if entry.state == PackageState::Updating {
|
||||
return Err(anyhow::anyhow!("{} is already updating", package_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
// `Updating`, so duplicating the check here would be a false positive.
|
||||
|
||||
install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await;
|
||||
|
||||
@@ -56,6 +53,64 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
// Preferred path: for single-container apps managed by manifests, route
|
||||
// updates through the orchestrator's upgrade lifecycle instead of the
|
||||
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
|
||||
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
|
||||
let orchestrator_app_id = orchestrator_update_app_id(package_id);
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
if health != "healthy" {
|
||||
warn!(
|
||||
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
|
||||
package_id, health
|
||||
);
|
||||
}
|
||||
}
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH OK: {} (app={})",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({
|
||||
"status": "updated",
|
||||
"package_id": package_id,
|
||||
}));
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
info!(
|
||||
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
|
||||
package_id
|
||||
);
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
install_log(&format!("UPDATE ORCH FAIL: {} — {}", package_id, e)).await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
|
||||
@@ -101,6 +156,11 @@ impl RpcHandler {
|
||||
containers: &[String],
|
||||
images_to_pull: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Phase: Preparing — about to stop the running container(s) so
|
||||
// we can swap images. Fast.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||
info!(
|
||||
"Update {}: stopping {} containers",
|
||||
@@ -130,6 +190,10 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: PullingImage — about to fetch each pinned image in turn.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
// 2. Pull new images with progress
|
||||
info!(
|
||||
"Update {}: pulling {} images",
|
||||
@@ -173,38 +237,23 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Recreate via reconcile script (single source of truth for container specs)
|
||||
info!("Update {}: recreating containers via reconcile", package_id);
|
||||
// Phase: CreatingContainer — about to recreate each container.
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// 4. Recreate containers (orchestrator-first, reconcile fallback)
|
||||
info!("Update {}: recreating containers", package_id);
|
||||
for name in containers {
|
||||
let out = tokio::process::Command::new("bash")
|
||||
.args([
|
||||
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||
&format!("--container={}", name),
|
||||
"--force",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to reconcile {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
error!(
|
||||
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
|
||||
package_id,
|
||||
name,
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Reconcile failed for {}: {}",
|
||||
name,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
self.recreate_container_for_update(package_id, name).await?;
|
||||
// Brief delay between containers for dependency initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy — reconcile has started every container,
|
||||
// now verifying each reached running state.
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
|
||||
// 5. Verify containers reached running state
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
for name in containers {
|
||||
@@ -226,34 +275,100 @@ impl RpcHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recreate_container_for_update(
|
||||
&self,
|
||||
package_id: &str,
|
||||
container_name: &str,
|
||||
) -> Result<()> {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot recreate {} during update {}: orchestrator unavailable",
|
||||
container_name,
|
||||
package_id
|
||||
));
|
||||
};
|
||||
|
||||
let mut attempted = Vec::new();
|
||||
for app_id in candidate_app_ids_for_container(container_name) {
|
||||
attempted.push(app_id.clone());
|
||||
match orchestrator.install(&app_id).await {
|
||||
Ok(created_name) => {
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
|
||||
package_id, container_name, app_id, created_name
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.context(format!(
|
||||
"orchestrator recreate failed for update {} (container={}, app_id={})",
|
||||
package_id, container_name, app_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
|
||||
container_name,
|
||||
package_id,
|
||||
attempted.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Pull a single image with progress broadcasting (reuses install progress pattern).
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
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())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.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));
|
||||
}
|
||||
@@ -296,15 +411,16 @@ impl RpcHandler {
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
|
||||
// Container was already removed — try reconcile to recreate with old image
|
||||
let _ = tokio::process::Command::new("bash")
|
||||
.args([
|
||||
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||
&format!("--container={}", name),
|
||||
"--force",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
// Container was already removed (forward path ran `podman rm`).
|
||||
// Recreate via orchestrator-first path with legacy fallback.
|
||||
if let Err(recreate_err) =
|
||||
self.recreate_container_for_update(package_id, name).await
|
||||
{
|
||||
error!(
|
||||
"Rollback: failed to recreate {} during rollback of {}: {}",
|
||||
name, package_id, recreate_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Rollback: failed to restart {}: {}", name, e);
|
||||
@@ -325,3 +441,129 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
|
||||
fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match container_name {
|
||||
"bitcoin-knots" | "bitcoin-core" => {
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin-core");
|
||||
}
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(container_name);
|
||||
if let Some(stripped) = container_name.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
);
|
||||
assert!(
|
||||
should_try_orchestrator_update(app, true),
|
||||
"{app} should use orchestrator when available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_orchestrator_means_no_orchestrator_flow() {
|
||||
assert!(!should_try_orchestrator_update("lnd", false));
|
||||
assert!(!should_try_orchestrator_update("btcpay-server", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_name_candidates_cover_common_aliases() {
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("bitcoin-knots"),
|
||||
vec!["bitcoin-knots", "bitcoin-core"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-bitcoin-ui"),
|
||||
vec!["bitcoin-ui", "archy-bitcoin-ui"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("mempool"),
|
||||
vec!["archy-mempool-web", "mempool"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-mempool-db"),
|
||||
vec!["archy-mempool-db", "mempool-db"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_aliases_map_to_manifest_app_ids() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ impl RpcHandler {
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user