Compare commits

...
Author SHA1 Message Date
archipelago bc94445ca0 chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:57:23 -04:00
archipelago 04cf0f663a chore: drop the superseded v1.8.8 prep for rebuild 2026-09-01 03:49:16 -04:00
archipelago 576c642da4 fix(apps): ollama resource type + adguardhome port; gate on collisions
Demo images / Build & push demo images (push) Failing after 38s
Ollama's embedded manifest failed the typed parse (memory_limit wants a
string) so the catalog overlay was skipped for it; AdGuard Home's
conventional :3000 collided with Grafana's. The release gate now runs
the host-port collision test (repo_app_manifests_have_no_host_port_collisions)
so this class can never ship untested again.
2026-09-01 03:31:02 -04:00
archipelago 12866db84a chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:16:10 -04:00
archipelago a184254706 style: rustfmt the ssh-mesh module 2026-09-01 02:42:39 -04:00
archipelago 192e045426 feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
2026-09-01 02:41:55 -04:00
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00
archipelago bf6ef9644c chore(apps): retire morphos-server, did-wallet, lightning-stack, cryptpad
Store-listing components are filtered via the shared serviceNames canon;
these four never earn a tile: MorphOS server is old, the Web5 DID wallet
and CryptPad are untested, Lightning Stack is an untracked upstream
bundle (LND covers it).
2026-09-01 02:41:55 -04:00
archipelago c32910809e chore: publish release v1.8.7-alpha 2026-09-01 01:42:12 -04:00
archipelago d2174128c5 chore: sign the v1.8.7 app catalog + release manifest 2026-09-01 01:37:24 -04:00
archipelago 2ad0171e5f fix(ui): drop the now-unused scheme helper
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 19:08:17 -04:00
archipelago 46cb0bfd37 fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
2026-08-31 18:41:00 -04:00
archipelago b8593c9090 docs(release): v1.8.7 notes — https app launches + platform round
Demo images / Build & push demo images (push) Failing after 34s
2026-08-31 18:40:51 -04:00
archipelago fc68c5b680 feat(apps): complete the manifest platform — convert the last five stragglers
Demo images / Build & push demo images (push) Failing after 36s
Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home now
carry full manifests: the app gate fronts their web ports (TLS on the
same port, node login where appropriate), installs run through the
orchestrator, and pins live in the signed catalog. Tailscale mirrors its
legacy shape exactly (userspace networking, web console on 8240, plain
HTTP for the gate to front). Ollama stays loopback-only — the
assistant's local model backend, not a web app.

Retires the four already-removed apps for good (FIPS, Nostr VPN,
Routstr, Penpot pins dropped from image-versions.sh, the generator map,
and image_versions.rs), fixes Cuprate's duplicated metadata block that
strict YAML parsers reject, and updates the port-inventory review gates
for the new open (3 own-login consoles) and exempt (2 DNS) ports.
2026-08-31 18:40:39 -04:00
archipelago 3ed75c328d style: rustfmt the signed-catalog serving 2026-08-31 17:09:16 -04:00
archipelago 687196ad3b chore: prepare release v1.8.7-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 17:08:37 -04:00
archipelago e2bd6330a1 test(app-catalog): pin the signed-catalog body gate 2026-08-31 17:08:37 -04:00
archipelago 7c0a492c43 fix(ui): launch apps on the page's scheme over HTTPS
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
2026-08-31 17:08:26 -04:00
archipelago 3089624969 Merge remote-tracking branch 'gitea-vps2/main' 2026-08-31 16:15:17 -04:00
archipelago 5b658cec67 feat(app-catalog): serve the signed catalog from the node first 2026-08-31 16:15:13 -04:00
archipelago 21b8d4b1ee catalog: add Cuprate (0.1.0-preview) 2026-08-31 16:10:38 -04:00
lfg2025 6f05f5583f Merge pull request 'docs: session record — companion 0.5.28 shipped + deployment playbook' (#151) from companion/session-2026-08-31 into main 2026-08-31 20:03:03 +00:00
Dorian 02ac4396d1 docs: session record — companion 0.5.28 shipped + the deployment playbook
Full state at session end (all public surfaces verified byte-identical
at 0.5.28/vc48; only node web-bundle redeploys outstanding), the feature
map, and the operational playbook next sessions need: Tor SOCKS proxy
for Gitea API/curl (the 'unreachable API' was a missing proxy flag),
token scopes, protected-main ship flow via -ship branch + PR + API merge,
stale local main lineage, the foundation server's two surfaces, demo CI
auto-redeploy, build/test commands, and the open items.
2026-08-31 21:02:59 +01:00
archipelago 5ffdcc9936 docs(release): explain the v1.8.7 correction
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:46:18 -04:00
archipelago 9cf07e1eac fix(release): enforce the v1.8 What's New floor
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:44:48 -04:00
archipelago e7854702c0 chore: publish release v1.8.6-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:40:23 -04:00
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
74 changed files with 4086 additions and 7618 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## v1.8.8-alpha (2026-09-01)
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
## v1.8.7-alpha (2026-08-31)
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
- **The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.
- **Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
## v1.8.6-alpha (2026-08-31)
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
## v1.8.5-alpha (2026-08-30)
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
+391 -355
View File
@@ -11,16 +11,47 @@
},
"apps": [
{
"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",
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.55",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "bitcoin-core",
@@ -35,76 +66,16 @@
"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.png",
"author": "Lightning Labs",
"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": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "botfights",
@@ -132,127 +103,46 @@
}
},
{
"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",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"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",
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"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": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.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/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"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": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
},
{
"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": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
"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": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "fedimint",
@@ -299,54 +189,87 @@
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"3535:3535"
"8083:80"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
"/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": "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": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"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",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"8096:8096"
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
"/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": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/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": "homeassistant",
@@ -370,11 +293,209 @@
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"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": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"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": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"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.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"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": "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": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.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/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"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": "ollama",
"title": "Ollama",
"version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama",
"category": "community",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"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": "source.archipelago-foundation.org/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": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
@@ -382,26 +503,44 @@
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"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": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"3000:3000"
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
@@ -433,51 +572,6 @@
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"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": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"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",
@@ -507,82 +601,24 @@
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"2342:2342"
"8082:80"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
+91
View File
@@ -0,0 +1,91 @@
app:
id: adguardhome
name: AdGuard Home
version: v0.107.55
upstream:
kind: github
repo: AdguardTeam/AdGuardHome
description: >-
Network-wide ad and tracker blocking: a DNS server that filters every
device on your LAN, with a web console for rules and client management.
container:
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 3030
container: 3000
protocol: tcp
bind: 127.0.0.1
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
# node, and both being installable means the host ports must not
# collide (the orchestrator refuses/loads warn on overlap).
# open: the setup wizard and admin console carry AdGuard Home's own
# login; the gate fronts the port (TLS, header fixes) without a
# second cookie challenge.
auth: open
auth_rationale: >-
AdGuard Home enforces its own admin login on the console, and the
first-run wizard must answer before any account exists.
- host: 53
container: 53
protocol: udp
# none: plain DNS must answer every unauthenticated query from LAN
# devices — a login page in front of :53 breaks every client on the
# network by design.
auth: none
auth_rationale: >-
Plain DNS answers unauthenticated by protocol: resolvers and clients
send queries directly; a login challenge would make DNS unreachable.
- host: 53
container: 53
protocol: tcp
auth: none
auth_rationale: >-
DNS-over-TCP fallback (truncated responses, zone transfers); same
protocol-level requirement as the UDP port.
volumes:
- type: bind
source: /var/lib/archipelago/adguardhome
target: /opt/adguardhome
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:3030
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin console
description: AdGuard Home web console
type: ui
port: 3030
protocol: http
path: /
metadata:
author: AdGuard
category: networking
repo: https://github.com/AdguardTeam/AdGuardHome
tier: optional
-5
View File
@@ -15,11 +15,6 @@ app:
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
metadata:
icon: /assets/img/app-icons/cuprate.svg
repo: https://github.com/Cuprate/cuprate
tier: optional
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-39
View File
@@ -1,39 +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 ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
-59
View File
@@ -1,59 +0,0 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"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"
}
}
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-5
View File
@@ -1,5 +0,0 @@
# Lightning Stack - uses official image
FROM lightninglabs/lightning-stack:v0.12.0
# Default configuration is in the image
# No additional setup needed
-85
View File
@@ -1,85 +0,0 @@
app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
image: lightninglabs/lightning-stack:v0.12.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 50Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 50Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: lightning-stack
ports:
- host: 9738
container: 9735
protocol: tcp # P2P
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10010
container: 10009
protocol: tcp # gRPC
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
source: /var/lib/archipelago/lightning-stack
target: /root/.lightning
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-37
View File
@@ -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 MORPHOS_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
-55
View File
@@ -1,55 +0,0 @@
app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
image: archipelago/morphos-server:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: morphos-server
ports:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/morphos-server
target: /app/data
options: [rw]
environment:
- MORPHOS_ENV=production
- MORPHOS_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "morphos-server",
"version": "1.0.0",
"description": "MorphOS server 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"
}
}
-27
View File
@@ -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: 'morphos-server', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'MorphOS Server',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`MorphOS Server listening on port ${port}`);
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+74
View File
@@ -0,0 +1,74 @@
app:
id: nginx-proxy-manager
name: Nginx Proxy Manager
version: 2.12.1
upstream:
kind: github
repo: NginxProxyManager/nginx-proxy-manager
description: >-
Reverse proxy with SSL. Beautiful web interface for managing proxies.
On a node, this manages its admin UI and upstream configuration — the
proxy's own :80/:443 listeners are not published (the node's web server
owns those ports).
container:
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8081
container: 81
protocol: tcp
bind: 127.0.0.1
# open, not gated: NPM carries a complete admin login of its own. The
# gate still fronts the port (TLS on the same port, header fixes, retry
# page, Tor) without putting a cookie challenge in front of it.
auth: open
auth_rationale: >-
Nginx Proxy Manager enforces its own admin account on every page;
the initial setup wizard also has to answer before any account exists.
volumes:
- type: bind
source: /var/lib/archipelago/nginx-proxy-manager
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:81
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin UI
description: Nginx Proxy Manager admin interface
type: ui
port: 8081
protocol: http
path: /
metadata:
author: Nginx Proxy Manager
category: networking
icon: /assets/img/app-icons/nginx.svg
repo: https://github.com/NginxProxyManager/nginx-proxy-manager
tier: optional
+63
View File
@@ -0,0 +1,63 @@
app:
id: ollama
name: Ollama
version: 0.5.4
upstream:
kind: github
repo: ollama/ollama
description: >-
Run large language models locally. Download and run AI models like
Llama, Mistral on your own hardware — served on the node's loopback for
the AI assistant (Settings → Claude Auth → model backend), never exposed
to the network.
container:
image: source.archipelago-foundation.org/lfg2025/ollama:latest
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 50Gi
resources:
# No memory limit: models are sized by the disk allowance below, and a
# RAM ceiling would just OOM-kill long inferences.
disk_limit: 50Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 11434
container: 11434
protocol: tcp
# local: Ollama's REST API is consumed by the node's own assistant over
# loopback — never externally reachable, so no gate, no TLS, and no
# login surface exist at all.
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/ollama
target: /root/.ollama
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:11434
interval: 30s
timeout: 5s
retries: 3
metadata:
author: Ollama
category: community
icon: /assets/img/app-icons/ollama.png
repo: https://github.com/ollama/ollama
tier: optional
+78
View File
@@ -0,0 +1,78 @@
app:
id: tailscale
name: Tailscale
version: 1.78.0
upstream:
kind: github
repo: tailscale/tailscale
description: Zero-config VPN with WireGuard mesh networking.
container:
image: source.archipelago-foundation.org/lfg2025/tailscale:stable
pull_policy: if-not-present
network: pasta
# Mirrors the legacy curated install exactly: tailscaled in userspace
# networking (no host TUN device needed — the rootless container cannot
# have one anyway), then `tailscale web` serving the console on :8240 as
# plain HTTP the app gate can front (TLS on the same port via the node
# certificate, framing-header fixes, retry page, Tor).
entrypoint: ["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"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8240
container: 8240
protocol: tcp
bind: 127.0.0.1
# open, not gated: the web console requires the tailnet's own login for
# every administrative action — the gate fronts the port without adding
# a second login in front of it.
auth: open
auth_rationale: >-
Tailscale's web console authenticates against the tailnet account for
all administrative actions; the node's cookie challenge would be a
second, redundant login.
volumes:
- type: bind
source: /var/lib/archipelago/tailscale
target: /var/lib/tailscale
options: [rw]
environment:
- TS_STATE_DIR=/var/lib/tailscale
health_check:
type: tcp
endpoint: localhost:8240
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web console
description: Tailscale web console
type: ui
port: 8240
protocol: http
path: /
metadata:
author: Tailscale
category: networking
icon: /assets/img/app-icons/tailscale.webp
repo: https://github.com/tailscale/tailscale
tier: recommended
+588
View File
@@ -0,0 +1,588 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "source.archipelago-foundation.org/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
"headline": "Stream Sovereignty",
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"apps": [
{
"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": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"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": "source.archipelago-foundation.org/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.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights",
"title": "BotFights",
"version": "1.2.11",
"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": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11",
"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 built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.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/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"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": "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": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"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": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"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": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"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": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"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": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.7.3",
"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": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"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": "source.archipelago-foundation.org/lfg2025/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",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/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": "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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"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": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"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": "source.archipelago-foundation.org/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": "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": "source.archipelago-foundation.org/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": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.5-alpha"
version = "1.8.8-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.5-alpha"
version = "1.8.8-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+15
View File
@@ -145,6 +145,21 @@ impl ApiHandler {
/// URL 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>> {
// The daemon already refreshes and verifies releases/app-catalog.json.
// Serve that release-root-anchored cache first so a newly published app
// appears immediately, without a frontend release. The old external UI
// catalog below is emergency compatibility only; it must never override
// a healthy signed catalog (Cuprate was invisible for exactly that reason).
if let Ok(body) =
crate::container::app_catalog::verified_catalog_body(&self.config.data_dir).await
{
return Ok(Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-cache")
.body(hyper::Body::from(body))?);
}
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
@@ -558,6 +558,11 @@ impl RpcHandler {
self.handle_fips_remove_seed_anchor(&p).await
}
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
"fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await,
"fips.ssh-over-mesh.set" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_ssh_over_mesh_set(&p).await
}
// System updates
"update.check" => self.handle_update_check().await,
+47
View File
@@ -261,4 +261,51 @@ impl RpcHandler {
}).collect::<Vec<_>>(),
}))
}
/// The SSH-over-mesh toggle state plus sshd preflights (the card explains
/// the rule instead of gating on it — see ssh_mesh.rs).
pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result<serde_json::Value> {
let state = fips::ssh_mesh::load(&self.config.data_dir).await;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"preflights": preflights,
}))
}
/// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` —
/// an empty/absent source list opens port 22 to every mesh peer (the UI
/// confirms that explicitly before calling with it).
pub(super) async fn handle_fips_ssh_over_mesh_set(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?;
let sources: Vec<String> = params
.get("sources")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (state, outcome) =
fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"applied": outcome.applied,
"removed": outcome.removed,
"reloaded": outcome.reloaded,
"preflights": preflights,
}))
}
}
@@ -24,6 +24,7 @@
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
//! publisher side never breaks older nodes.
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -194,6 +195,27 @@ fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
load_catalog().apps.get(app_id).cloned()
}
/// Return the cached catalog bytes only when they carry a signature anchored
/// to the release root. This is the browser App Store's source: newly signed
/// apps must appear without waiting for a frontend OTA, while unsigned or
/// self-signed registry data must never become an install button.
pub async fn verified_catalog_body(data_dir: &Path) -> anyhow::Result<String> {
let path = data_dir.join(APP_CATALOG_FILE);
let body = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("read signed app catalog {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Verified { anchored: true, .. } => Ok(body),
crate::trust::SignatureStatus::Verified {
anchored: false, ..
} => {
anyhow::bail!("app catalog signer is not anchored to the release root")
}
crate::trust::SignatureStatus::Unsigned => anyhow::bail!("app catalog is unsigned"),
}
}
/// Primary image for an app per the remote catalog, if covered.
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
entry_for(app_id).and_then(|e| e.image)
@@ -641,4 +663,27 @@ mod tests {
]
);
}
// The signed-catalog body served to the browser must be the anchored,
// release-root-verified bytes — and nothing else. Unsigned caches (the
// migration-window form) and self-consistent-but-unanchored signatures
// must both be refused so a tampered mirror can never become an install
// button (same posture as the OTA manifest supply-chain gate).
#[tokio::test]
async fn verified_catalog_body_rejects_unsigned_cache() {
let dir = tempfile::tempdir().unwrap();
write_cache(
dir.path(),
r#"{"schema":1,"apps":{"demo":{"version":"1"}}}"#,
)
.unwrap();
let err = verified_catalog_body(dir.path()).await.unwrap_err();
assert!(err.to_string().contains("unsigned"));
}
#[tokio::test]
async fn verified_catalog_body_rejects_missing_cache() {
let dir = tempfile::tempdir().unwrap();
assert!(verified_catalog_body(dir.path()).await.is_err());
}
}
@@ -141,6 +141,12 @@ impl DockerPackageScanner {
// Get metadata for this app
let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
// manifest is what the catalog signed and what the App Store shows,
// so it is also what an installed tile must render.
let manifest_icon = real_manifest_metadata(&app_id)
.and_then(|m| m.get("icon").and_then(|v| v.as_str()).map(str::to_string))
.filter(|s| !s.trim().is_empty());
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if app_id == "netbird" {
@@ -191,7 +197,7 @@ impl DockerPackageScanner {
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
icon: metadata.icon.clone(),
icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
},
manifest: Manifest {
id: app_id.clone(),
@@ -211,28 +217,34 @@ impl DockerPackageScanner {
author: Some("Archipelago".to_string()),
website: lan_address.clone(),
tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() {
interfaces: {
// `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the
// Services tab can still show where a backend lives.
// port. A DECLARED UI classifies the app as launchable
// even when no reachable address was confirmed this
// scan — the launch button falls back to the static
// port map, and burying a manifest-declared UI app
// (Alby Hub) in Services because a probe missed was
// exactly the classification bug this fixes.
let has_ui = super::ui_detection::has_web_ui(
&app_id,
lan_address.as_deref(),
package_state == PackageState::Running,
)
.await;
Some(Interfaces {
main: Some(MainInterface {
ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(),
lan_config: None,
}),
})
} else {
None
if lan_address.is_some() || tor_address.is_some() || has_ui {
Some(Interfaces {
main: Some(MainInterface {
ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(),
lan_config: None,
}),
})
} else {
None
}
},
},
available_update,
@@ -322,6 +334,47 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
&& right.chars().all(|c| c.is_ascii_lowercase())
}
/// Raw `metadata` block of an installed app's real manifest — catalog overlay
/// first (origin-wins), disk manifest as fallback. Kept as raw JSON because
/// the typed `AppManifest` deliberately does not model `metadata`, yet its
/// `icon` is what makes an installed app's tile render the right icon on
/// every surface (My Apps, Services, launcher, companion) instead of the
/// generic A-mark — the exact regression Cuprate exposed on install.
fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
for (id, value) in crate::container::app_catalog::catalog_manifest_values() {
if id == app_id {
return value.get("app").and_then(|a| a.get("metadata")).cloned();
}
}
let mut candidates = Vec::new();
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
candidates.push(
std::path::PathBuf::from(dir)
.join("../apps")
.join(app_id)
.join("manifest.yml"),
);
}
candidates.push(
std::path::PathBuf::from("/opt/archipelago/apps")
.join(app_id)
.join("manifest.yml"),
);
for path in candidates {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else {
continue;
};
let meta = value.get("app").and_then(|a| a.get("metadata")).cloned();
if meta.is_some() {
return meta;
}
}
None
}
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin-core" => AppMetadata {
@@ -163,7 +163,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
"searxng" => Some("SEARXNG_IMAGE"),
"cryptpad" => Some("CRYPTPAD_IMAGE"),
"filebrowser" => Some("FILEBROWSER_IMAGE"),
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
@@ -178,18 +177,10 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Nostr / VPN
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
"fips" => Some("FIPS_IMAGE"),
// Immich (primary = server)
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
@@ -341,13 +332,6 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
("immich_redis", "REDIS_IMAGE"),
("immich_server", "IMMICH_SERVER_IMAGE"),
],
"penpot" | "penpot-frontend" => vec![
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
"netbird" => vec![
("netbird", "NETBIRD_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
+8
View File
@@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
}
}
// SSH-over-mesh rides every config install so the on-state survives
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
// this module owns the 90-ssh.nft slot exclusively).
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
+1
View File
@@ -32,6 +32,7 @@ pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod ssh_mesh;
pub mod telemetry;
pub mod update;
+492
View File
@@ -0,0 +1,492 @@
//! SSH over the FIPS mesh — a first-class settings toggle.
//!
//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/
//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins
//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct
//! by default — but the user asked to be able to SSH their node from Termux
//! over the phone's FIPS mesh instead of keeping a second VPN around for it,
//! and the mesh path already works end-to-end (verified live: the connect
//! reaches fips0 and gets a RST from the node).
//!
//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly
//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this
//! feature can never fight over the same slot:
//!
//! * toggle OFF → drop-in removed, port 22 refused again
//! * toggle ON → drop-in written on every toggle change AND on every
//! daemon config install (upgrade, reconnect, self-heal),
//! so the on-state survives reinstalls idempotently
//! * scope → "any" (every mesh peer — a real exposure, gated in the
//! UI behind an explicit confirmation) or an explicit list
//! of mesh addresses
//!
//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to
//! `config.rs`, and the sshd process itself is entirely the operator's.
use std::net::Ipv6Addr;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
/// On-disk state under the archipelago data dir. Absent file = disabled,
/// which is the safe default for every node that never touched the toggle.
const STATE_FILE: &str = "fips-ssh-over-mesh.json";
/// The drop-in slot this module owns. 90 sorts after the daemon's own
/// drop-ins (80/85) so a human reading the directory sees the deliberate
/// order; the include order does not change semantics for plain accepts.
pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft";
/// The hardening baseline this drop-in hangs off. Same file `config.rs`
/// reloads after its own drop-ins.
const FIPS_NFT: &str = "/etc/fips/fips.nft";
/// Persisted toggle state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SshMeshState {
/// Whether port 22 is allowed through the fips0 baseline at all.
#[serde(default)]
pub enabled: bool,
/// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh
/// peer. Kept as strings as-entered but validated as IPv6 on save.
#[serde(default)]
pub sources: Vec<String>,
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(STATE_FILE)
}
/// Load the persisted state. Missing file = disabled, no sources — never an
/// error, so a fresh node and a deleted file both mean "off".
pub async fn load(data_dir: &Path) -> SshMeshState {
match tokio::fs::read_to_string(state_path(data_dir)).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SshMeshState::default(),
}
}
/// Validate and normalise an operator-supplied source list. Every entry must
/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) —
/// anything else is refused with the offending entry named, so a typo can
/// never silently narrow or widen the rule.
pub fn validate_sources(raw: &[String]) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(raw.len());
for entry in raw {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let addr: Ipv6Addr = trimmed
.parse()
.with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?;
out.push(addr.to_string());
}
out.dedup();
Ok(out)
}
/// Render the nft drop-in for a state. The rule shape mirrors the interim
/// manual unblock from the field notes (`ip6 saddr <ula> tcp dport 22
/// accept`) — an unrestricted rule is the same statement without the saddr.
pub fn render_dropin(state: &SshMeshState) -> String {
let mut out = String::from(
"# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\
# Allows sshd (port 22) through the fips0 default-deny inbound\n\
# baseline. Remove = refused again; never edit 80/85-* by hand.\n",
);
if state.sources.is_empty() {
out.push_str("tcp dport 22 accept\n");
} else {
out.push_str(&format!(
"ip6 saddr {{ {} }} tcp dport 22 accept\n",
state.sources.join(", ")
));
}
out
}
/// Write or remove the drop-in to match the persisted state, then reload the
/// baseline so the change is live immediately. Returns whether a reload was
/// attempted and succeeded — a node without the hardening baseline has
/// nothing to reload (port 22 is governed by sshd and the host firewall
/// there), which is reported rather than treated as failure.
pub async fn reconcile(data_dir: &Path) -> Result<ReconcileOutcome> {
let state = load(data_dir).await;
if !state.enabled {
let removed = remove_dropin().await?;
let reloaded = reload_nft().await;
return Ok(ReconcileOutcome {
applied: false,
removed,
reloaded,
});
}
// Ensure /etc/fips/fips.d exists, exactly like config::install.
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"])
.output()
.await
.context("sudo install -d /etc/fips/fips.d")?;
if !out.status.success() {
anyhow::bail!(
"sudo install -d /etc/fips/fips.d failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let dropin = render_dropin(&state);
let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id()));
tokio::fs::write(&stage, &dropin)
.await
.context("stage ssh nft drop-in")?;
let install = Command::new("sudo")
.args(["install", "-m", "0644"])
.arg(&stage)
.arg(DROPIN_PATH)
.output()
.await;
let _ = tokio::fs::remove_file(&stage).await;
let install = install?;
if !install.status.success() {
anyhow::bail!(
"install {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&install.stderr).trim()
);
}
let reloaded = reload_nft().await;
Ok(ReconcileOutcome {
applied: true,
removed: false,
reloaded,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileOutcome {
/// The allow rule is in place.
pub applied: bool,
/// A previously-written drop-in was removed this call.
pub removed: bool,
/// The hardening baseline existed and `nft -f` succeeded.
pub reloaded: bool,
}
async fn remove_dropin() -> Result<bool> {
match tokio::fs::try_exists(DROPIN_PATH).await {
Ok(true) => {}
_ => return Ok(false),
}
let out = Command::new("sudo")
.args(["rm", "-f", DROPIN_PATH])
.output()
.await
.context("sudo rm 90-ssh.nft")?;
if !out.status.success() {
anyhow::bail!(
"removing {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again");
Ok(true)
}
/// Reload the hardening baseline. Best-effort in the same spirit as
/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed
/// reload is Ok(false) with a warn, never an error — the drop-in is on disk
/// either way and the next daemon install reloads it.
async fn reload_nft() -> bool {
match tokio::fs::try_exists(FIPS_NFT).await {
Ok(true) => {}
_ => return false,
}
match Command::new("sudo")
.args(["nft", "-f", FIPS_NFT])
.output()
.await
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"ssh-over-mesh: nft reload failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
false
}
Err(e) => {
tracing::warn!("ssh-over-mesh: nft reload failed: {e}");
false
}
}
}
/// Persist new state and reconcile immediately. Validation happens here so
/// an invalid source list can never reach disk, and reconcile reads back
/// exactly what was saved.
pub async fn set(
data_dir: &Path,
enabled: bool,
sources: &[String],
) -> Result<(SshMeshState, ReconcileOutcome)> {
let state = SshMeshState {
enabled,
sources: validate_sources(sources)?,
};
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?)
.await
.with_context(|| format!("write {}", state_path(data_dir).display()))?;
let outcome = reconcile(data_dir).await?;
Ok((state, outcome))
}
/// Preflights surfaced in the settings card. None of these gate the toggle —
/// they explain it: writing the rule on a node whose sshd doesn't listen on
/// IPv6 simply has no effect until sshd does, and the card says so instead of
/// the user discovering it as a silent connection failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshPreflights {
/// ssh.service (or sshd.service) is active.
pub sshd_active: bool,
/// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`).
/// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it.
pub sshd_ipv6_listen: bool,
/// sshd_config's PasswordAuthentication (last directive wins, includes
/// after the main file). None = not found / unreadable.
pub password_auth: Option<bool>,
}
pub async fn preflights() -> SshPreflights {
SshPreflights {
sshd_active: sshd_active().await,
sshd_ipv6_listen: sshd_ipv6_listen().await,
password_auth: password_auth_enabled().await,
}
}
async fn sshd_active() -> bool {
for unit in ["ssh", "sshd"] {
if let Ok(out) = Command::new("systemctl")
.args(["is-active", "--quiet", unit])
.output()
.await
{
if out.status.success() {
return true;
}
}
}
false
}
async fn sshd_ipv6_listen() -> bool {
let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else {
return false;
};
let text = String::from_utf8_lossy(&out.stdout);
text.lines().any(|line| {
let mut cols = line.split_whitespace();
// -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4.
let _state = cols.next();
let _recv = cols.next();
let _send = cols.next();
match cols.next() {
Some(local) => {
let port_ok = local.rsplit(':').next() == Some("22");
let v6 = local.starts_with("[::]") || local.starts_with('*');
port_ok && v6
}
None => false,
}
})
}
async fn password_auth_enabled() -> Option<bool> {
let mut directives: Vec<bool> = Vec::new();
if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await {
collect_password_auth(&main, &mut directives);
}
if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await {
for path in includes {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
collect_password_auth(&content, &mut directives);
}
}
}
directives.pop()
}
fn collect_password_auth(content: &str, out: &mut Vec<bool>) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") {
let rest = rest.trim_start();
let value = rest.split_whitespace().next().unwrap_or("");
if value.eq_ignore_ascii_case("yes") {
out.push(true);
} else if value.eq_ignore_ascii_case("no") {
out.push(false);
}
}
}
}
async fn glob_sorted(pattern: &str) -> Result<Vec<std::path::PathBuf>> {
let dir = std::path::Path::new(pattern)
.parent()
.unwrap_or_else(|| Path::new("/"));
let prefix = std::path::Path::new(pattern)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.split('.').next())
.unwrap_or("")
.to_string();
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut entries = tokio::fs::read_dir(dir)
.await
.context("read sshd_config.d")?;
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".conf") {
files.push(entry.path());
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_is_the_default_and_missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let state = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert!(!state.enabled);
assert!(state.sources.is_empty());
}
#[test]
fn any_peer_dropin_is_an_unrestricted_accept() {
let state = SshMeshState {
enabled: true,
sources: vec![],
};
let out = render_dropin(&state);
assert!(out.contains("tcp dport 22 accept"));
assert!(!out.contains("ip6 saddr"), "no saddr restriction expected");
}
#[test]
fn source_list_dropin_restricts_to_those_addresses() {
let state = SshMeshState {
enabled: true,
sources: vec![
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
"fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(),
],
};
let out = render_dropin(&state);
assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept"));
}
#[test]
fn sources_must_be_ipv6_and_are_normalised() {
let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err();
assert!(bad.to_string().contains("192.168.1.5"));
let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err();
assert!(bad.to_string().contains("not-an-address"));
// Uppercase/whitespace entries normalise to canonical lowercase.
let ok = validate_sources(&[
" FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(),
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
String::new(),
])
.unwrap();
assert_eq!(
ok,
vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]
);
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let state = SshMeshState {
enabled: true,
sources: vec!["fd00::1".to_string()],
};
std::fs::write(
dir.path().join(STATE_FILE),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
let loaded = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert_eq!(loaded, state);
}
#[test]
fn set_validates_before_persisting() {
let dir = tempfile::tempdir().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(set(dir.path(), true, &["bogus".to_string()]))
.unwrap_err();
assert!(err.to_string().contains("bogus"));
// Nothing was persisted.
let state = rt.block_on(load(dir.path()));
assert!(!state.enabled);
}
#[test]
fn preflight_parse_helpers_cover_the_directives() {
let mut directives = Vec::new();
collect_password_auth(
"# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n",
&mut directives,
);
assert_eq!(directives, vec![true, false]);
}
#[test]
fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() {
assert!(line_listens("[::]:22"));
assert!(line_listens("*:22"));
assert!(!line_listens("0.0.0.0:22"));
assert!(!line_listens("[::]:80"));
}
fn line_listens(local: &str) -> bool {
let line = format!("LISTEN 0 128 {local} 0.0.0.0:*");
let mut cols = line.split_whitespace();
cols.next();
cols.next();
cols.next();
match cols.next() {
Some(l) => {
let port_ok = l.rsplit(':').next() == Some("22");
let v6 = l.starts_with("[::]") || l.starts_with('*');
port_ok && v6
}
None => false,
}
}
}
+17 -5
View File
@@ -1746,6 +1746,11 @@ app:
}
}
exempt.sort();
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
// same reason router's mDNS/SSDP and every p2p port is exempt; each
// carries its auth_rationale in the manifest.
//
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
@@ -1771,7 +1776,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
28,
30,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
@@ -1801,15 +1806,22 @@ app:
}
}
open.sort();
// Gitea 3001 (git clients speak basic-auth, not browser cookies) and
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
// by anonymous payers). Both enforce their own account login, and an
// operator can re-gate either from Settings → Access control.
// by anonymous payers), and — since the v1.8.7 platform round — the
// three own-login consoles brought onto the manifest platform:
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
// (tailnet login on the web console), adguardhome 3000 (AGH admin
// accounts + first-run wizard). All enforce their own login, and an
// operator can re-gate any of them from Settings → Access control.
assert_eq!(
open,
vec![
("adguardhome".to_string(), 3000u16),
("btcpay-server".to_string(), 23000u16),
("gitea".to_string(), 3001u16)
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16),
],
"gate-open port set changed — every entry must be an app with its own login"
);
@@ -0,0 +1,74 @@
# HANDOFF — deploy companion 0.5.28 (vc48) to the live surfaces
**For: the agent on archi-dev-box.** Companion 0.5.28 shipped to `main`
today (PR #149, merge `9f1a289d` — backup & restore #128, NIP-46 remote
signer #139, companion-gated install pitch #61 residual, hub sub-pages).
The dev box verified everything it can reach; three live surfaces remain,
same shape as the 2026-07-23 deploy handoff
([`HANDOFF-2026-07-23-companion-apk-deploy.md`](HANDOFF-2026-07-23-companion-apk-deploy.md)).
## Already done and verified (do not redo)
- `neode-ui/public/packages/archipelago-companion.apk` on `main` is
**0.5.28 / versionCode 48**, clean build via `Android/ship-companion.sh`,
**v1+v2+v3 signatures verified**, meta json refreshed beside it.
- Gitea raw-on-main serves it byte-identical:
`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`
(2 bytes: 28,206,999… file size ≈ 28.2 MB).
- The foundation server's **raw-proxy** path already serves 0.5.28 (verified
via `https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json`).
- Demo CI (`demo-images.yml`) fired on the push and redeploys the stack via
the Portainer webhook — should flip on its own; confirm only.
- Signing key unchanged (cert SHA-256 `d622e07e…ec2664d`), so phones update
**in place** over any 0.5.27 install.
## 1. Foundation server static `/packages/` mirror — the real-node QR URL
`https://source.archipelago-foundation.org/packages/archipelago-companion.apk`
is a **static dir** on the release server (openresty; still 0.5.27,
last-modified 2026-08-17). This is the exact URL real nodes' companion QR
downloads (`DEFAULT_DOWNLOAD_URL` in `CompanionIntroOverlay.vue`) — it must
flip before the release is done.
```bash
# Find the webroot once:
grep -rl "packages" /etc/openresty /etc/nginx 2>/dev/null
find / -name archipelago-companion.apk -not -path '/proc/*' 2>/dev/null
# Mirror the exact bytes from Gitea raw-on-main (no rebuild, no re-sign):
cd <that webroot>
curl -fsS -o archipelago-companion.apk http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk
curl -fsS -o archipelago-companion.json http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json
shasum -a 256 archipelago-companion.apk
# MUST print: fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca
```
## 2. Node web-bundle redeploys
Same as 2026-07-23: redeploy the web-ui bundle from current `main` to the
active nodes — web root `/opt/archipelago/web-ui/` (NOT a `neode-ui/`
subfolder), at minimum every node the user pairs against. The APK rides in
the bundle's `packages/` dir, so this is also what makes each node's own
served QR download 0.5.28.
## 3. Confirm the demo flipped
`curl -s http://146.59.87.168:2100/packages/archipelago-companion.json`
should read 0.5.28/48 once CI's Portainer webhook redeploy lands; trigger a
stack redeploy if it lags.
## Final verify (all three must show 0.5.28 / 48)
```bash
aapt2 dump badging <downloaded apk> | head -1 # versionCode='48' versionName='0.5.28-debug'
apksigner verify -v --min-sdk-version 21 <downloaded apk> | grep scheme # v1/v2/v3 true
curl -s https://source.archipelago-foundation.org/packages/archipelago-companion.json
curl -s http://146.59.87.168:2100/packages/archipelago-companion.json
```
Then the user's on-device end-to-end: scan the node's companion QR →
installs vc48 in place → hub → Backup & Restore / Remote Signer.
Testing notes for the new features live in the closed tracker issues
(#61/#128/#139) and `docs/companion-backup-restore.md` /
`docs/companion-nip46-remote-signer.md` (the signer's e2e harness:
`Android/tools/nip46-test-client.py`).
+154
View File
@@ -0,0 +1,154 @@
# SESSION — companion 0.5.28: shipped, published, playbook (2026-08-31)
**For: the companion agent (next session) + anyone shipping a companion
release.** Session that closed the 2026-08-30 companion-agent queue (#61
residual, #128, #139) and shipped 0.5.28 end-to-end.
## Release state at session end — ALL LIVE
Companion **0.5.28 / versionCode 48**, main @ PR #149 (`9f1a289d`), deploy
handoff merged as PR #150 (`91374392`). Every public surface verified
byte-identical (`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`):
| Surface | URL | State |
|---|---|---|
| Gitea raw-on-main | `http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk` | ✅ 0.5.28, v1+v2+v3 verified on download |
| Foundation static `/packages/` (real-node QR URL) | `https://source.archipelago-foundation.org/packages/archipelago-companion.apk` | ✅ 0.5.28 |
| Foundation Gitea-raw proxy | `…/lfg2025/archy/raw/branch/main/…` | ✅ 0.5.28 (6h cache — may lag after pushes) |
| Demo `:2100` | `http://146.59.87.168:2100/packages/archipelago-companion.apk` | ✅ 0.5.28 (auto: CI + Portainer webhook) |
Only remaining live-surface step: **node web-bundle redeploys** so each
node's own served copy is 0.5.28 — archi-dev-box's standard step, written up
in `docs/HANDOFF-2026-08-31-companion-0.5.28-deploy.md` (its §1/§3 were
already done by the time of this doc — only §2 outstanding).
Tracker: #128 and #139 closed with what-shipped comments; #61 (already
closed) got a residual-fix follow-up. Signing cert unchanged (`d622e07e…`),
so phones update in place.
## What shipped in 0.5.28 (map)
- **#61 residual (web)**: `isCompanionApp()` gates on `CompanionBanner.vue`
render, `openCompanionIntro()` (useCompanionIntro.ts), and the overlay's
manual-open watcher; overlay moved to the canonical helper. Vitest suite
green (1013 tests).
- **#128 Backup & Restore**: `Android/rust/archy-fips-core/src/backup.rs`
(ADR-005 envelope, node-compatible), `BackupManager.kt`, hub sub-page
`ui/components/BackupSection.kt`. Doc: `companion-backup-restore.md`.
- **#139 Remote Signer**: `src/nostr.rs` (NIP-44 v2 + NIP-04 + BIP-340,
official vectors), `nostr/BunkerManager.kt` + `NostrSignerPreferences.kt`,
hub sub-page `ui/components/SignerSection.kt`, `nostrconnect://` deep link
via `SignerLaunch`. Harness: `Android/tools/nip46-test-client.py`.
Doc: `companion-nip46-remote-signer.md`.
- **Hub modal redesign** (field feedback): both features are sub-pages like
Nodes/FIPS; panel height cap 70%; scanner hosted by NESMenu outside the
panel; back-arrow → hub.
- **Extras**: node mesh ULA shown/copyable in the Nodes list (`MenuItem`
subtitle); `Android/tools/fipssh` (npub→ULA is pure: `fd ‖ sha256(pubkey)[0..15]`,
pinned by `npub_derives_the_same_mesh_ula_as_the_fips_identity` test).
- **Node-side handoffs written**: `HANDOFF-2026-08-31-ssh-over-mesh.md`
(SSH-over-mesh toggle) and the 0.5.28 deploy handoff.
## The deployment playbook (learned the hard way this session)
### Networking — everything goes through the Tor SOCKS proxy
Direct connections to `146.59.87.168` fail from this box ("Bad file
descriptor"); git works because `~/.gitconfig` sets
`proxy = socks5h://127.0.0.1:9050`. **For curl/Gitea API you must pass it
explicitly:**
```bash
curl -s --socks5-hostname 127.0.0.1:9050 ... # works
curl -s ... # HTTP 000, "unreachable"
```
This is why earlier sessions concluded "Gitea API unreachable" — wrong; it
just needs the proxy flag.
### Gitea API + auth
- Base: `http://146.59.87.168:3000/api/v1` (v1.27.1), via the proxy.
- The keychain git credential (`security find-internet-password -s
146.59.87.168`, acct `v4v`) is a **`write:repository`-only token** — fine
for git, CANNOT read/write issues.
- Issue ops need `write:issue`. This session the user pasted a broad token
(activitypub+misc+notification+organization+package+issue+repository) —
**revocation still pending** (it's in chat scrollback). Ask the user for a
scoped `write:issue` token next time.
### main is PROTECTED — ship via -ship branch + PR + API merge
`git push origin main` is rejected by pre-receive. The working sequence:
```bash
git fetch origin
git checkout main && git reset --hard origin/main # local main is STALE (see below)
git merge --no-ff companion/<ver> -m "Companion <ver> — …"
./Android/ship-companion.sh # builds, signs v1+v2+v3, stages APK+meta, commits
# its `git push` FAILS on protected main — expected. Push the branch instead:
git push origin main:companion/<ver>-ship
# then create + merge the PR via API:
curl ... POST repos/lfg2025/archy/pulls {"head":"companion/<ver>-ship","base":"main",...}
curl ... POST repos/lfg2025/archy/pulls/<n>/merge -d '{"Do":"merge"}'
```
(Refinement for next time: run `ship-companion.sh` ON the `-ship` branch
from the start — it pushes the current branch, which for a `-ship` branch
succeeds directly.)
- **Local `main` is the pre-open-source-import lineage** (1115 stale
commits, unrelated history). Always `reset --hard origin/main` before
using it; never merge into it without the reset.
- A **stale tag ref** (`v1.7.115-alpha`) can make `git fetch` fail
("did not send all necessary objects") — `rm .git/refs/tags/v1.7.115-alpha`.
- Last release's `-ship` branch for reference: `origin/companion/0.5.27-ship`.
### Build + verify (per release)
- Version lives in `Android/app/build.gradle.kts` (`versionCode` must
strictly increase; meta json is auto-generated by the publish script from
it). 0.5.28 → next is **0.5.29/vc49**.
- APK package is `com.archipelago.app.debug` (the served artifact IS the
debug build, committed repo keystore, cert SHA-256 `d622e07e…ec2664d`).
Local `Android/app/debug.keystore` is untracked but produces that cert —
verify per release: `apksigner verify --print-certs` on old vs new.
- Build: `cd Android && JAVA_HOME=/opt/homebrew/opt/openjdk@17
ANDROID_HOME=$HOME/Library/Android/sdk ./gradlew :app:assembleDebug`
(builds the Rust via cargo-ndk, NDK under `~/Library/Android/sdk/ndk/`).
Test build for the user: copy to `~/Desktop/archipelago-companion-<ver>.apk`.
- Rust: `cd Android/rust/archy-fips-core && cargo test --lib` (24 tests at
session end) + clippy. neode-ui: `npm ci` first (node_modules not kept),
`npx vitest run`, `npm run type-check`.
- Post-ship verify block: aapt2 badging, shasum vs Gitea raw, apksigner
v1/v2/v3, the three public URLs' meta json (table above), foundation
raw-proxy may serve up to 6h stale (cache-control: max-age=21600).
### Infrastructure facts
- `source.archipelago-foundation.org` = openresty on vps2 with **two
surfaces**: static `/packages/` (manual mirror; the real-node QR URL) and
a Gitea-raw proxy (6h cache, auto). Demo `:2100` redeploys automatically:
`.gitea/workflows/demo-images.yml` fires on `main` pushes touching
`neode-ui/**`, then calls the Portainer webhook.
- **No SSH to vps2 from this box**: `archy_146_release` key declined for
root/archipelago/dorian/lfg2025/deploy/git. Server-side work needs the
archi-dev-box agent or the user.
## Open items for next session
1. **Confirm node web-bundle redeploys** happened (archi-dev-box; deploy
handoff §2) — a paired node's own `/packages/` should serve vc48.
2. **Token revocation** (user) + request a `write:issue`-scoped one.
3. **Node-side roadmap** fed by this release: SSH-over-mesh toggle
(`HANDOFF-2026-08-31-ssh-over-mesh.md`), node NIP-46 client (login flow B),
node-side storage for companion backup envelopes.
4. **On-device follow-ups**: the user's full 0.5.28 pass — signer e2e via
the harness (`/tmp/nip46env/bin/python Android/tools/nip46-test-client.py`),
backup round-trip on a wipe, and the zxing-cpp decision trigger
(move-to-the-code; sketch is verified online:
`io.github.zxing-cpp:android:3.1.1`, still NOT-actioned by design).
5. Untracked on this box, deliberately left: `Android/app/debug.keystore`,
`docs/1.8-alpha-improvements-tracker.md`,
`docs/SESSION-1.8.0-OTA-PROGRESS.md`, `image-recipe/branding/source-logos/`
(other workstreams' files).
@@ -573,7 +573,7 @@ RUN mkdir -p /etc/polkit-1/rules.d && \
# already-deployed nodes over OTA (idempotent no-op here once applied).
RUN set -eu; \
apt-get update; \
apt-get install -y --no-install-recommends kdump-tools kexec-tools rasdaemon; \
apt-get install -y --no-install-recommends kdump-tools kexec-tools makedumpfile rasdaemon; \
apt-get clean; rm -rf /var/lib/apt/lists/*; \
CONF=/etc/default/kdump-tools; \
sed -i 's|^#\?USE_KDUMP=.*|USE_KDUMP="1"|' "$CONF"; \
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.5-alpha",
"version": "1.8.8-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.5-alpha",
"version": "1.8.8-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.5-alpha",
"version": "1.8.8-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+391 -355
View File
@@ -11,16 +11,47 @@
},
"apps": [
{
"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",
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.55",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "bitcoin-core",
@@ -35,76 +66,16 @@
"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.png",
"author": "Lightning Labs",
"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": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "botfights",
@@ -132,127 +103,46 @@
}
},
{
"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",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"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",
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"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": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.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/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"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": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
},
{
"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": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
"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": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "fedimint",
@@ -299,54 +189,87 @@
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"3535:3535"
"8083:80"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
"/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": "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": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"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",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"8096:8096"
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
"/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": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/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": "homeassistant",
@@ -370,11 +293,209 @@
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"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": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"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": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"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.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"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": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"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": "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": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.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/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"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": "ollama",
"title": "Ollama",
"version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama",
"category": "community",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"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": "source.archipelago-foundation.org/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": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
@@ -382,26 +503,44 @@
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"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": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"3000:3000"
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
@@ -433,51 +572,6 @@
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"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": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"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",
@@ -507,82 +601,24 @@
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"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",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"2342:2342"
"8082:80"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"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": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
@@ -1,5 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
// The signed catalog's embedded manifests decide which ports the app gate
// fronts (TLS on the same port) — prime the same shape the live catalog
// carries for the apps these tests launch.
const SIGNED = {
apps: {
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }] } } },
'nginx-proxy-manager': { version: 'latest' }, // legacy: no manifest → http
},
}
// vi.hoisted runs before vi.mock hoisting
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
@@ -23,6 +35,7 @@ describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
__setSignedCatalogForTests(SIGNED as never)
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' },
@@ -256,6 +269,46 @@ describe('useAppLauncherStore', () => {
)
})
// An HTTPS connection must never hand the remote browser (or the phone
// webview) a cleartext app URL: same-host app ports are gate-owned and
// serve TLS on the same port. Plain-http pages keep http exactly as before
// — pinned by every test above this one.
it('upgrades same-host app URLs to https on an https page', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:8082', title: 'Vaultwarden' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.0.2.10:8082',
'_blank',
'noopener,noreferrer',
)
})
it('never upgrades a different host on an https page', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.100:8082', title: 'Vaultwarden' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.100:8082',
'_blank',
'noopener,noreferrer',
)
})
it('opens Gitea path URL in new tab', () => {
const store = useAppLauncherStore()
+21 -42
View File
@@ -4,7 +4,8 @@ import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { portIsGateFronted } from '@/views/discover/curatedApps'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
@@ -60,9 +61,6 @@ const NEW_TAB_APP_IDS = new Set([
'netbird',
])
// Apps served over HTTPS (self-signed) rather than plain HTTP.
const HTTPS_APP_IDS = new Set(['netbird'])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -147,33 +145,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
const APP_ID_TO_PORT: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
@@ -285,18 +257,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
// its OIDC dashboard) must be launched over https — a stale http URL hits
// the TLS port and 400s. Upgrade the scheme defensively in every path.
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
try {
const u = new URL(launchUrl, window.location.origin)
if (u.protocol === 'http:') {
u.protocol = 'https:'
launchUrl = u.href
}
} catch { /* leave as-is */ }
}
// Scheme discipline for everything launched on this host. Ports fronted
// by the node's app gate (manifest auth gated/open) serve TLS on the same
// port — on an HTTPS connection those must open over https. Ports that
// are NOT gate-fronted (legacy curated installs like Nginx Proxy Manager,
// Tailscale; `auth: none` publishes) are plain HTTP and https would fail
// to connect outright, so they keep http. External hosts keep their own
// scheme.
try {
const u = new URL(launchUrl, window.location.origin)
const sameHost = u.hostname === window.location.hostname
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
const httpsPage = window.location.protocol === 'https:'
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
// Pure prefix swap — never re-serialize the URL (URL.href would add
// a trailing slash and change the string the caller handed over).
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
}
} catch { /* leave as-is */ }
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
+16 -6
View File
@@ -184,6 +184,7 @@ import {
categorizeCommunityApp,
getCuratedAppList,
} from './marketplace/marketplaceData'
import { fetchAppCatalog } from './discover/curatedApps'
const router = useRouter()
const route = useRoute()
@@ -238,10 +239,17 @@ watch(() => route.query.category, (category) => {
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
// TTL) behind a shared key so Discover.vue's identical loader picks up the
// same cache entry without its own conversion (plan 02-04). Non-sensitive
// and small, so it persists across reloads.
// and small, so it persists across reloads. Dynamic-catalog-first: the
// daemon-verified signed catalog is what makes a newly published app appear
// without a dashboard release — the static list below is only the offline
// fallback (same fetcher contract as Discover.vue for this shared key).
const catalogResource = useCachedResource<MarketplaceApp[]>({
key: 'app-catalog',
fetcher: async () => getCuratedAppList(),
fetcher: async () => {
const catalog = await fetchAppCatalog()
if (catalog && catalog.apps.length) return catalog.apps
return getCuratedAppList()
},
ttlMs: 300_000,
persist: true,
})
@@ -507,8 +515,10 @@ async function installApp(app: MarketplaceApp) {
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
// Stay on the store page: the tile itself shows install progress via the
// global watcher, and a forced jump to My Apps yanked the user out of the
// page they were deliberately browsing.
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@@ -532,8 +542,8 @@ async function installCommunityApp(app: MarketplaceApp) {
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
// Stay on the store page (see installApp).
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
@@ -1,8 +1,39 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
import { describe, expect, it, beforeEach } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
// that decides TLS eligibility). Kept minimal — only what the scheme logic
// consults.
const SIGNED = {
apps: {
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }, { host: 2222, auth: 'none' }] } } },
'btcpay-server': { version: '2.4.3', manifest: { app: { ports: [{ host: 23000, auth: 'open' }] } } },
mempool: { version: '3.3.1', manifest: { app: { ports: [{ host: 4080, auth: 'gated' }] } } },
filebrowser: { version: '2.27.0', manifest: { app: { ports: [{ host: 8083, auth: 'gated' }] } } },
// Legacy curated installs — in the community list, NOT in the signed
// catalog's manifests. Their ports publish plain HTTP: https fails.
'nginx-proxy-manager': { version: 'latest' },
tailscale: { version: 'stable' },
// auth:none ports are container-published too — https would fail.
cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } },
},
}
function stubLocation(value: { hostname: string; protocol: string }) {
Object.defineProperty(window, 'location', {
value,
writable: true,
configurable: true,
})
}
describe('appSessionConfig', () => {
beforeEach(() => {
__setSignedCatalogForTests(SIGNED as never)
})
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
@@ -56,7 +87,9 @@ describe('appSessionConfig', () => {
configurable: true,
})
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
// NetBird's dashboard needs a secure context (OIDC PKCE), so it is
// ALWAYS launched over https — on either page scheme.
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('https://192.0.2.10:8087')
})
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
@@ -66,6 +99,52 @@ describe('appSessionConfig', () => {
configurable: true,
})
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('http://192.0.2.10:8083')
})
// The direct-port launch path (new-tab apps on desktop, the companion's
// native WebView on phones) used to hardcode http:// — so a node reached
// over HTTPS opened Vaultwarden and friends in cleartext. It must follow
// the page scheme ONLY for ports the app gate fronts (TLS on the same
// port); legacy installs without manifests (Nginx Proxy Manager, Tailscale)
// and auth:none ports stay on http or https would fail to connect.
it('builds direct app URLs on the page scheme — https page, gate-fronted app', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000')
})
it('keeps legacy manifest-less apps on http even on an https page', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
expect(directAppUrl('tailscale')).toBe('http://192.0.2.10:8240')
})
it('keeps plain-http direct app URLs on a plain-http page', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082')
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
})
it('always launches secure-context apps over https, on either page scheme', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
})
it('resolves session app URLs on the page scheme for gate-fronted ports only (https page)', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080')
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('https://192.0.2.10:8083')
// A runtime port the gate does NOT front keeps plain http (https would
// fail to connect outright).
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
})
})
@@ -1,5 +1,6 @@
/** Static configuration maps for app session routing and display */
import { portIsGateFronted } from '../discover/curatedApps'
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
@@ -107,15 +108,20 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
return appOrigin(8334)
return appOrigin(8334, id)
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
// content and the frame is blocked outright, so follow the page instead.
base = matchPageScheme(base)
// binds locally. On an HTTPS dashboard that is mixed content and the
// frame is blocked outright — but ONLY upgrade when the gate fronts the
// port (it serves TLS there); a container-published plain-HTTP port
// would fail to connect over https at all.
try {
const port = new URL(base).port
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
} catch { /* keep as-is */ }
if (routeQueryPath) base += routeQueryPath
return base
}
@@ -124,13 +130,14 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const port = APP_PORTS[id]
if (!port) return ''
let base = appOrigin(port)
let base = appOrigin(port, id)
if (routeQueryPath) base += routeQueryPath
return base
}
/**
* An app's origin on this host, on the SAME scheme as the page.
* An app's origin on this host, on the SAME scheme as the page when the
* app gate fronts the port (TLS on the same port), plain http otherwise.
*
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
* mixed content before any cookie question arises — and it is also what makes
@@ -143,8 +150,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
* Node certificate. A certificate warning cannot be accepted inside an iframe,
* so an untrusted app port renders nothing rather than prompting.
*/
export function appOrigin(port: number): string {
return `${pageScheme()}//${window.location.hostname}:${port}`
export function appOrigin(port: number, appId?: string): string {
const https = appId
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
: pageScheme() === 'https:'
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
}
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
@@ -166,6 +176,52 @@ function pageScheme(): string {
return p === 'https:' || p === 'http:' ? p : 'http:'
}
/** Apps served over HTTPS (self-signed) rather than plain HTTP, regardless of
* the page's scheme. */
export const HTTPS_APP_IDS = new Set(['netbird'])
/** App ID -> direct launch port for the paths that bypass the in-app session:
* new-tab apps and the companion's native WebView. Every port here is owned
* by the app gate (manifest `auth: gated`/`open` + `bind: 127.0.0.1`), which
* serves TLS on the same port whenever the node has a certificate. */
export const DIRECT_APP_PORTS: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
/** Direct-port launch URL for an app, on the page's scheme.
*
* These are the apps that open OUTSIDE the dashboard's own origin — a new
* browser tab on the desktop, or the companion's in-app WebView on a phone.
* The URL is handed to a context with no dashboard chrome, so it must carry
* the scheme the remote browser actually reached the node on: on an HTTPS
* connection, `http://host:port` is at best a silent downgrade to cleartext
* and at worst blocked outright as mixed content. Every port in
* DIRECT_APP_PORTS is served by the app gate with TLS on the same port
* (see appgate/tls.rs), so following the page scheme is always answerable.
* Plain-HTTP dashboards keep today's behaviour exactly. */
export function directAppUrl(appId: string): string | null {
const port = DIRECT_APP_PORTS[appId]
if (!port || typeof window === 'undefined') return null
const scheme = HTTPS_APP_IDS.has(appId)
|| (portIsGateFronted(appId, port) && pageScheme() === 'https:')
? 'https'
: 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
/** Resolve a human-readable title for an app */
export function resolveAppTitle(id: string): string {
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
+21 -36
View File
@@ -3,35 +3,20 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '../appSession/appSessionConfig'
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
import { portIsGateFronted } from '../discover/curatedApps'
import { isAutoTabApp } from '@/utils/autoTabApps'
import {
SERVICE_NAMES as SHARED_SERVICE_NAMES,
isServiceContainer as sharedIsServiceContainer,
} from './serviceNames'
export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper', 'pine-openwakeword',
])
// Re-exported for every existing caller — the canon moved to serviceNames.ts
// so the App Store's catalog merge can share it without a circular import.
export const SERVICE_NAMES = SHARED_SERVICE_NAMES
export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default',
@@ -42,16 +27,7 @@ export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): bo
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
}
export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false
}
// isServiceContainer now re-exports the shared canon (serviceNames.ts).
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isServiceContainer(id)) return true
@@ -299,7 +275,16 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
const addr = runtimeLanAddress(pkg)
if (!addr || typeof window === 'undefined') return addr
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally — on an HTTPS connection that is a cleartext downgrade.
// Upgrade only when the app gate fronts the port (it serves TLS there);
// a container-published plain-HTTP port would fail over https outright.
try {
const port = new URL(local).port
if (!portIsGateFronted(pkg.manifest.id, port)) return local
} catch { /* keep as-is */ }
return matchPageScheme(local)
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
+62
View File
@@ -0,0 +1,62 @@
/**
* Canonical internal/service app-id classification — the shared source for
* every surface that must decide "is this a user-facing app or a component
* of one": the My Apps/Services tabs (appsConfig) and the App Store listing
* (the signed-catalog merge in discover/curatedApps must not publish
* components as standalone store apps).
*
* Keep this module dependency-free: it is imported from both the apps view
* and the catalog module (which must not import view code that reads the
* store — circulars).
*/
// Service container ids (backend/infra, not user-facing). Mirrors the
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper', 'pine-openwakeword',
])
// Node-bundled internals that are real manifests (so they surface in the
// signed catalog's manifest list) but are not store apps: the assistant and
// the bundled relay/UIs ship with the node itself, the DWN is internal
// plumbing, and the mesh router + CLN are components of other surfaces.
export const NODE_INTERNAL_IDS = new Set([
'aiui', 'fips-ui', 'strfry', 'web5-dwn', 'router', 'core-lightning',
])
/** Is this id a component/backend rather than a standalone user app? */
export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false
}
/** Should this app id appear in the App Store listing at all? */
export function isStoreListedApp(id: string): boolean {
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
}
+1 -1
View File
@@ -40,7 +40,7 @@
v-if="app.icon"
:src="app.icon"
:alt="app.title"
class="w-14 h-14 rounded-lg object-cover"
class="w-14 h-14 rounded-lg archy-app-icon"
@error="handleImageError"
/>
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
+1 -1
View File
@@ -24,7 +24,7 @@
v-if="app.icon"
:src="app.icon"
:alt="app.title"
class="w-20 h-20 rounded-xl object-cover flex-shrink-0"
class="w-20 h-20 rounded-xl archy-app-icon flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 min-w-0">
+131 -17
View File
@@ -1,4 +1,5 @@
import type { MarketplaceApp } from './types'
import { isStoreListedApp } from '../apps/serviceNames'
const R = 'source.archipelago-foundation.org/lfg2025'
@@ -18,17 +19,99 @@ export interface AppCatalog {
apps: MarketplaceApp[]
}
/** Shape of the release-signed catalog (`releases/app-catalog.json`) served
* by the daemon at /api/app-catalog after release-root verification. `apps`
* is keyed by app id and each entry embeds the app's full manifest — the
* ports[] there (auth: gated/open/none) are what decides whether a port is
* fronted by the node's app gate (and therefore serves TLS on the same
* port) or published by the container as plain HTTP. */
export interface SignedAppCatalog {
schema?: number
updated?: string
apps: Record<string, SignedAppEntry>
}
export interface SignedAppEntry {
version: string
image?: string
manifest?: {
app?: {
id?: string
name?: string
version?: string
description?: string
category?: string
container?: { image?: string }
metadata?: { icon?: string; author?: string; repo?: string }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
}
}
/** Convert the signed catalog's keyed entries into store-listing apps.
* Pure — unit-tested against the live catalog's shape (Cuprate).
* Components and node internals never enter the listing: the signed catalog
* carries every manifest (including backends like mempool-api and the
* bundled UIs) because the node's update layer needs their pins, but the
* App Store publishes apps, not parts of apps. */
export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] {
const out: MarketplaceApp[] = []
for (const [id, entry] of Object.entries(catalog.apps || {})) {
if (!isStoreListedApp(id)) continue
const app = entry.manifest?.app
out.push({
id,
title: app?.name || id,
version: entry.version || app?.version || '',
description: app?.description || '',
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
author: app?.metadata?.author,
dockerImage: entry.image || app?.container?.image || '',
repoUrl: app?.metadata?.repo,
category: app?.category,
source: 'signed-catalog',
})
}
return out
}
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
* after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null
/** Port auth for an app's host port, from the signed catalog's embedded
* manifest. `gated`/`open` = the node's app gate owns the port and serves
* TLS on it; `none`/`local` = container-published plain HTTP; null = app
* unknown to the signed catalog (legacy curated installs). */
export function portAuth(appId: string, hostPort: number | string): string | null {
const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports
if (!Array.isArray(ports)) return null
const hit = ports.find(p => String(p.host) === String(hostPort))
return hit?.auth ?? null
}
/** Whether an app's host port is fronted by the node's app gate (and so
* serves TLS alongside HTTP on the same port). Unknown apps are NOT —
* assuming TLS for a container-published port breaks it outright. */
export function portIsGateFronted(appId: string, hostPort: number | string): boolean {
const auth = portAuth(appId, hostPort)
return auth === 'gated' || auth === 'open'
}
export function __setSignedCatalogForTests(catalog: SignedAppCatalog | null) {
signedCatalogCache = catalog
}
let cachedCatalog: AppCatalog | null = null
let catalogFetchedAt = 0
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
/** Catalog URLs tried in order. First success wins.
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
* fallback. If the backend is offline (mid-restart etc.) we fall back
* to the static copy baked into the frontend build. */
/** Catalog URLs for the community listing. The signed catalog is served by
* the backend proxy (`/api/app-catalog`) — server-side fetch bypasses CORS
* on the upstream Gitea and verifies the release-root signature. If the
* backend is offline (mid-restart etc.) the static community copy baked
* into the frontend build still renders the store. */
const CATALOG_URLS = [
'/api/app-catalog',
'/catalog.json',
]
@@ -38,29 +121,61 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
// The daemon-verified signed catalog first (release-root signature checked
// server-side): it is what makes a newly published app appear without a
// dashboard release. The community catalog supplies the featured banner
// and curated copy for shared ids; signed-only ids join the listing as-is.
let signedApps: MarketplaceApp[] = []
let signedOk = false
try {
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
if (res.ok) {
const data = await res.json() as SignedAppCatalog
if (data.apps && !Array.isArray(data.apps)) {
signedCatalogCache = data
signedApps = signedCatalogToApps(data)
signedOk = signedApps.length > 0
}
}
} catch { /* fall through to the community catalog */ }
let community: AppCatalog | null = null
for (const url of CATALOG_URLS) {
try {
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
if (!res.ok) continue
const data = await res.json() as AppCatalog
if (!data.apps?.length) continue
// Expand short docker image refs to full registry paths
const registry = data.registry || R
for (const app of data.apps) {
if (app.dockerImage && !app.dockerImage.includes('/')) {
app.dockerImage = `${registry}/${app.dockerImage}`
}
}
cachedCatalog = data
catalogFetchedAt = Date.now()
// Cache in localStorage for offline fallback
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
return data
} catch (e) {
console.warn(`[catalog] fetch failed for ${url}:`, e)
continue
community = data
break
} catch { /* try the next source */ }
}
if (signedOk || community) {
// Community copy wins for shared ids (curated descriptions, webUrl-only
// apps); signed entries fill version/image gaps and append brand-new apps.
const byId = new Map<string, MarketplaceApp>()
for (const app of signedApps) byId.set(app.id, app)
for (const app of community?.apps ?? []) {
const existing = byId.get(app.id)
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app)
}
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
apps: [...byId.values()],
}
cachedCatalog = merged
catalogFetchedAt = Date.now()
try { localStorage.setItem('archy_catalog', JSON.stringify(merged)) } catch {}
return merged
}
// Try localStorage cache as final fallback
@@ -91,7 +206,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
@@ -247,17 +247,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/ollama/ollama'
},
{
id: 'cryptpad',
title: 'CryptPad',
version: '2024.12.0',
description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.',
icon: '/assets/icon/favico-black-v2.svg',
author: 'XWiki SAS',
dockerImage: `${REGISTRY}/cryptpad:2024.12.0`,
manifestUrl: undefined,
repoUrl: 'https://github.com/cryptpad/cryptpad'
},
{
id: 'nextcloud',
title: 'Nextcloud',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useToast } from '@/composables/useToast'
interface SshOverMeshStatus {
enabled: boolean
sources: string[]
scope: 'any' | 'list'
preflights?: {
sshd_active: boolean
sshd_ipv6_listen: boolean
password_auth: boolean | null
}
applied?: boolean
reloaded?: boolean
}
const toast = useToast()
const status = ref<SshOverMeshStatus | null>(null)
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const confirmAnyPeer = ref(false)
const pendingEnableAny = ref(false)
const sourcesText = ref('')
const fipsNpub = ref<string | null>(null)
async function refresh() {
loading.value = true
error.value = ''
try {
status.value = (await rpcClient.call({ method: 'fips.ssh-over-mesh.get' })) as SshOverMeshStatus
sourcesText.value = (status.value.sources || []).join('\n')
if (!fipsNpub.value) {
try {
const s = (await rpcClient.call<{ npub?: string }>({ method: 'fips.status', dedup: true, maxRetries: 1 }))
fipsNpub.value = s.npub ?? null
} catch { /* npub hint is optional */ }
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load SSH-over-mesh state'
} finally {
loading.value = false
}
}
/** The unrestricted scope opens port 22 to every mesh peer — demand an
* explicit confirmation before applying it, like the other danger zones. */
async function toggle(enabled: boolean) {
const sources = enabled && status.value?.scope === 'list'
? sourcesText.value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
: (status.value?.sources ?? [])
if (enabled && sources.length === 0) {
pendingEnableAny.value = true
confirmAnyPeer.value = true
return
}
await apply(enabled, sources)
}
async function apply(enabled: boolean, sources: string[]) {
saving.value = true
try {
status.value = (await rpcClient.call({
method: 'fips.ssh-over-mesh.set',
params: { enabled, sources },
timeout: 30_000,
})) as SshOverMeshStatus
sourcesText.value = (status.value.sources || []).join('\n')
toast.success(enabled ? 'SSH over mesh allowed' : 'SSH over mesh blocked')
if (enabled && status.value.reloaded === false) {
toast.error('Rule saved, but the firewall reload failed — reconnect or check logs')
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to apply')
} finally {
saving.value = false
pendingEnableAny.value = false
confirmAnyPeer.value = false
}
}
function copyFipssh() {
if (!fipsNpub.value) return
const cmd = `fipssh <user>@${fipsNpub.value}`
navigator.clipboard?.writeText(cmd).then(
() => toast.success('Copied: ' + cmd),
() => toast.error('Copy failed'),
)
}
onMounted(refresh)
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h2 class="text-xl font-semibold text-white/96">SSH over mesh</h2>
<p class="text-sm text-white/60 mt-1">
Let the phone's FIPS mesh reach this node's SSH (port 22). Off by default — the mesh
firewall refuses SSH until you allow it here.
</p>
</div>
<button
class="path-action-button path-action-button--continue px-4 py-2 text-sm shrink-0"
:disabled="saving || loading"
:class="{ '!bg-orange-500/30 !border-orange-400/50': status?.enabled }"
@click="toggle(!status?.enabled)"
>
{{ status?.enabled ? 'Allowed — block again' : 'Allow SSH over mesh' }}
</button>
</div>
<div v-if="loading" class="text-sm text-white/50 py-3">Loading…</div>
<div v-else-if="error" class="text-sm text-red-300 py-3">{{ error }}
<button class="ml-2 underline" @click="refresh">Retry</button>
</div>
<template v-else-if="status">
<!-- Preflights: explain the toggle rather than gating it -->
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2">
<span :class="status.preflights?.sshd_active ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">sshd {{ status.preflights?.sshd_active ? 'is running' : 'is NOT running' }}</span>
</div>
<div class="flex items-center gap-2">
<span :class="status.preflights?.sshd_ipv6_listen ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">
{{ status.preflights?.sshd_ipv6_listen ? 'listens on IPv6 (mesh-reachable)' : 'does not listen on IPv6 — the mesh cannot reach it' }}
</span>
</div>
<div class="flex items-center gap-2">
<span :class="status.preflights?.password_auth === false ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">
{{ status.preflights?.password_auth == null
? 'PasswordAuthentication unknown'
: status.preflights?.password_auth
? 'password login allowed — keys-only is the safer pairing for the firewall rule'
: 'keys-only login (recommended)' }}
</span>
</div>
</div>
<!-- Source restriction -->
<div class="mt-4">
<p class="text-xs text-white/50 mb-1">Restrict to specific mesh addresses (one per line), or leave empty to allow any mesh peer.</p>
<textarea
v-model="sourcesText"
rows="2"
class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-sm text-white/90 font-mono placeholder-white/30"
placeholder="fd79:… phone mesh address"
:disabled="!status.enabled || saving"
@change="apply(true, sourcesText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean))"
/>
</div>
<!-- Phone-side hint: the npub is the durable address -->
<div v-if="status.enabled && fipsNpub" class="mt-3 flex items-center gap-2 text-sm">
<span class="text-white/60">From Termux:</span>
<code class="text-orange-200 truncate max-w-[55%]">fipssh &lt;user&gt;@{{ fipsNpub.slice(0, 12) }}…</code>
<button class="glass-button px-2 py-1 text-xs" @click="copyFipssh">Copy</button>
</div>
</template>
<!-- Danger-zone confirmation for the unrestricted scope -->
<Teleport to="body">
<div v-if="confirmAnyPeer" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="confirmAnyPeer = false">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-3">Allow SSH from ANY mesh peer?</h3>
<p class="text-sm text-white/70 mb-4">
Anyone who can route to this node over the FIPS mesh will reach port 22. Restricting
to your phone's mesh address above is the safer pairing. Only continue if you
understand port 22 will face every mesh peer.
</p>
<div class="flex gap-3">
<button class="flex-1 glass-button px-4 py-2 text-sm" @click="confirmAnyPeer = false">Restrict instead</button>
<button
class="flex-1 px-4 py-2 rounded-lg text-sm border border-orange-400/50 bg-orange-500/20 text-orange-200 hover:bg-orange-500/30"
:disabled="saving || pendingEnableAny === false"
@click="apply(true, [])"
>I understand — allow any</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import SshOverMeshCard from '@/views/settings/SshOverMeshCard.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
@@ -20,6 +21,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<AIDataAccessSection />
<WebhookSection />
<TelemetrySection />
<SshOverMeshCard />
<NodeCertificateSection />
<LightningCredentialsSection />
<BackupSection />
@@ -0,0 +1,92 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const getSpy = vi.fn()
const setSpy = vi.fn()
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn() }
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (req: { method: string; params?: unknown }) => {
if (req.method === 'fips.ssh-over-mesh.get') return getSpy(req)
if (req.method === 'fips.ssh-over-mesh.set') return setSpy(req)
if (req.method === 'fips.status') return Promise.resolve({ npub: 'npub1test' })
return Promise.resolve({})
},
},
}))
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
import SshOverMeshCard from '../SshOverMeshCard.vue'
function statusBody(overrides: Record<string, unknown> = {}) {
return {
enabled: false,
sources: [],
scope: 'any',
preflights: { sshd_active: true, sshd_ipv6_listen: true, password_auth: false },
...overrides,
}
}
describe('SshOverMeshCard', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
getSpy.mockResolvedValue(statusBody())
setSpy.mockResolvedValue(statusBody({ enabled: true }))
})
it('renders preflights from the RPC state', async () => {
getSpy.mockResolvedValue(statusBody())
const wrapper = mount(SshOverMeshCard)
await vi.dynamicImportSettled()
await new Promise(r => setTimeout(r, 0))
expect(getSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'fips.ssh-over-mesh.get' }))
expect(wrapper.text()).toContain('sshd is running')
expect(wrapper.text()).toContain('keys-only login')
})
it('flags the missing IPv6 listener honestly', async () => {
getSpy.mockResolvedValue(statusBody({
preflights: { sshd_active: true, sshd_ipv6_listen: false, password_auth: true },
}))
const wrapper = mount(SshOverMeshCard)
await new Promise(r => setTimeout(r, 0))
expect(wrapper.text()).toContain('does not listen on IPv6')
expect(wrapper.text()).toContain('keys-only is the safer pairing')
})
it('demands the danger-zone confirmation before an unrestricted enable', async () => {
const wrapper = mount(SshOverMeshCard)
await new Promise(r => setTimeout(r, 0))
await wrapper.find('button.path-action-button').trigger('click')
// The confirmation modal Teleports to <body>; nothing applied yet.
expect(document.body.textContent).toContain('Allow SSH from ANY mesh peer')
expect(setSpy).not.toHaveBeenCalled()
// Confirming applies the unrestricted rule.
const confirm = [...document.body.querySelectorAll('button')]
.find(b => (b.textContent ?? '').includes('I understand'))
expect(confirm).toBeDefined()
confirm!.dispatchEvent(new Event('click'))
await new Promise(r => setTimeout(r, 0))
await new Promise(r => setTimeout(r, 0))
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
params: { enabled: true, sources: [] },
}))
})
it('applies a restricted enable without the any-peer confirmation', async () => {
// An existing restricted rule: turning it back on re-applies the list.
getSpy.mockResolvedValue(statusBody({ enabled: false, sources: ['fd00::1'], scope: 'list' }))
setSpy.mockResolvedValue(statusBody({ enabled: true, sources: ['fd00::1'], scope: 'list' }))
const wrapper = mount(SshOverMeshCard)
await new Promise(r => setTimeout(r, 0))
await wrapper.find('button.path-action-button').trigger('click')
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
params: { enabled: true, sources: ['fd00::1'] },
}))
expect(wrapper.text()).not.toContain('Allow SSH from ANY mesh peer')
})
})
+19 -18
View File
@@ -1,31 +1,32 @@
{
"changelog": [
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
"**What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.",
"**The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.",
"**Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.",
"**Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.",
"**Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.",
"**Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it."
],
"components": [
{
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
"current_version": "1.8.7-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.5-alpha",
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
"size_bytes": 63934504
"new_version": "1.8.7-alpha",
"sha256": "572accec81e73fbcd5218ddc41f5ec719deda30f104c838398fdfa85a7a276f0",
"size_bytes": 63990960
},
{
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
"new_version": "1.8.5-alpha",
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
"size_bytes": 97657151
"current_version": "1.8.7-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago-frontend-1.8.7-alpha.tar.gz",
"name": "archipelago-frontend-1.8.7-alpha.tar.gz",
"new_version": "1.8.7-alpha",
"sha256": "26b8c12b1b3e6b5c93841f3713037f65d0ea6a52401bd28888eb8d2f5dc483b5",
"size_bytes": 97779181
}
],
"release_date": "2026-08-31",
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
"signature": "79332435d436e7bae1a5b0c12158b02083b681b6818ac90219021f1e39eb61a3e1039ed38ee8089006a0dec862fd92997e44a6c9ccf0be17e91a95cba5de6402",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.5-alpha"
"version": "1.8.7-alpha"
}
+325 -271
View File
@@ -2,6 +2,98 @@
"apps": {
"adguardhome": {
"image": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
"network": "pasta",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"environment": [],
"health_check": {
"endpoint": "localhost:3030",
"interval": "30s",
"retries": 3,
"timeout": "5s",
"type": "tcp"
},
"id": "adguardhome",
"interfaces": {
"main": {
"description": "AdGuard Home web console",
"name": "Admin console",
"path": "/",
"port": 3030,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"author": "AdGuard",
"category": "networking",
"repo": "https://github.com/AdguardTeam/AdGuardHome",
"tier": "optional"
},
"name": "AdGuard Home",
"ports": [
{
"auth": "open",
"auth_rationale": "AdGuard Home enforces its own admin login on the console, and the first-run wizard must answer before any account exists.",
"bind": "127.0.0.1",
"container": 3000,
"host": 3030,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Plain DNS answers unauthenticated by protocol: resolvers and clients send queries directly; a login challenge would make DNS unreachable.",
"container": 53,
"host": 53,
"protocol": "udp"
},
{
"auth": "none",
"auth_rationale": "DNS-over-TCP fallback (truncated responses, zone transfers); same protocol-level requirement as the UDP port.",
"container": 53,
"host": 53,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "1Gi",
"memory_limit": "512Mi"
},
"security": {
"capabilities": [
"NET_BIND_SERVICE"
],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "AdguardTeam/AdGuardHome"
},
"version": "v0.107.55",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/adguardhome",
"target": "/opt/adguardhome",
"type": "bind"
}
]
}
},
"version": "v0.107.55"
},
"aiui": {
@@ -1205,10 +1297,6 @@
},
"version": "23.08.2"
},
"cryptpad": {
"image": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
"version": "2024.12.0"
},
"cuprate": {
"manifest": {
"app": {
@@ -1299,79 +1387,6 @@
},
"version": "0.1.0-preview"
},
"did-wallet": {
"manifest": {
"app": {
"container": {
"image": "archipelago/did-wallet:1.0.0",
"image_signature": "cosign://...",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "2Gi"
}
],
"description": "Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.",
"environment": [
"WALLET_STORAGE=/app/wallet"
],
"health_check": {
"endpoint": "http://127.0.0.1:8080",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "did-wallet",
"name": "Web5 DID Wallet",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8088,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 1,
"disk_limit": "2Gi",
"memory_limit": "512Mi"
},
"security": {
"apparmor_profile": "did-wallet",
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/did-wallet",
"target": "/app/wallet",
"type": "bind"
}
],
"web5_integration": {
"bitcoin_integration": true,
"did_support": true,
"wallet_functionality": true
}
}
},
"version": "1.0.0"
},
"electrs-ui": {
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
"manifest": {
@@ -1951,10 +1966,6 @@
},
"version": "v2.27.0"
},
"fips": {
"image": "source.archipelago-foundation.org/lfg2025/fips:v0.1.0",
"version": "v0.1.0"
},
"fips-ui": {
"manifest": {
"app": {
@@ -3196,106 +3207,6 @@
},
"version": "10.11.11"
},
"lightning-stack": {
"manifest": {
"app": {
"bitcoin_integration": {
"rpc_access": "admin",
"sync_required": true
},
"container": {
"image": "lightninglabs/lightning-stack:v0.12.0",
"image_signature": "cosign://...",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"app_id": "bitcoin-core",
"version": ">=24.0"
},
{
"storage": "50Gi"
}
],
"description": "Complete Lightning Network implementation. Includes LND, CLN, and management tools.",
"environment": [
"BITCOIND_HOST=bitcoin-core",
"BITCOIND_RPCUSER=${BITCOIN_RPC_USER}",
"BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}",
"NETWORK=mainnet"
],
"health_check": {
"endpoint": "http://127.0.0.1:8080",
"interval": "30s",
"path": "/v1/getinfo",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "lightning-stack",
"lightning_integration": {
"channel_management": true,
"payment_routing": true
},
"name": "Lightning Stack",
"ports": [
{
"auth": "none",
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
"container": 9735,
"host": 9738,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.",
"container": 10009,
"host": 10010,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.",
"container": 8080,
"host": 8091,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 4,
"disk_limit": "50Gi",
"memory_limit": "4Gi"
},
"security": {
"apparmor_profile": "lightning-stack",
"capabilities": [
"NET_BIND_SERVICE"
],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "manual",
"url": "no public listing for lightninglabs/lightning-stack — verify by hand"
},
"version": "0.12.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/lightning-stack",
"target": "/root/.lightning",
"type": "bind"
}
]
}
},
"version": "0.12.0"
},
"lnd": {
"image": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"manifest": {
@@ -3651,75 +3562,6 @@
},
"version": "3.0.0"
},
"morphos-server": {
"manifest": {
"app": {
"container": {
"image": "archipelago/morphos-server:1.0.0",
"image_signature": "cosign://...",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "5Gi"
}
],
"description": "MorphOS server platform. Decentralized application server.",
"environment": [
"MORPHOS_ENV=production",
"MORPHOS_DATA_DIR=/app/data"
],
"health_check": {
"endpoint": "http://127.0.0.1:8080",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "morphos-server",
"name": "MorphOS Server",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8089,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 2,
"disk_limit": "5Gi",
"memory_limit": "2Gi"
},
"security": {
"apparmor_profile": "morphos-server",
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/morphos-server",
"target": "/app/data",
"type": "bind"
}
]
}
},
"version": "1.0.0"
},
"netbird": {
"manifest": {
"app": {
@@ -4145,6 +3987,88 @@
},
"nginx-proxy-manager": {
"image": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"network": "pasta",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"environment": [],
"health_check": {
"endpoint": "localhost:81",
"interval": "30s",
"retries": 3,
"timeout": "5s",
"type": "tcp"
},
"id": "nginx-proxy-manager",
"interfaces": {
"main": {
"description": "Nginx Proxy Manager admin interface",
"name": "Admin UI",
"path": "/",
"port": 8081,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"author": "Nginx Proxy Manager",
"category": "networking",
"icon": "/assets/img/app-icons/nginx.svg",
"repo": "https://github.com/NginxProxyManager/nginx-proxy-manager",
"tier": "optional"
},
"name": "Nginx Proxy Manager",
"ports": [
{
"auth": "open",
"auth_rationale": "Nginx Proxy Manager enforces its own admin account on every page; the initial setup wizard also has to answer before any account exists.",
"bind": "127.0.0.1",
"container": 81,
"host": 8081,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "1Gi",
"memory_limit": "512Mi"
},
"security": {
"capabilities": [
"CHOWN",
"SETUID",
"SETGID",
"DAC_OVERRIDE"
],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "NginxProxyManager/nginx-proxy-manager"
},
"version": "2.12.1",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/nginx-proxy-manager",
"target": "/data",
"type": "bind"
}
]
}
},
"version": "latest"
},
"nostr-rs-relay": {
@@ -4226,24 +4150,74 @@
},
"version": "0.10.0"
},
"nostr-vpn": {
"image": "source.archipelago-foundation.org/lfg2025/nostr-vpn:v0.3.7",
"version": "v0.3.7"
},
"ollama": {
"image": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"version": "latest"
},
"penpot": {
"image": "source.archipelago-foundation.org/lfg2025/penpot-frontend:2.4",
"images": {
"penpot-backend": "source.archipelago-foundation.org/lfg2025/penpot-backend:2.4",
"penpot-exporter": "source.archipelago-foundation.org/lfg2025/penpot-exporter:2.4",
"penpot-frontend": "source.archipelago-foundation.org/lfg2025/penpot-frontend:2.4",
"penpot-postgres": "source.archipelago-foundation.org/lfg2025/postgres:15",
"penpot-valkey": "source.archipelago-foundation.org/lfg2025/valkey:8.1"
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"network": "pasta",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "50Gi"
}
],
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
"environment": [],
"health_check": {
"endpoint": "localhost:11434",
"interval": "30s",
"retries": 3,
"timeout": "5s",
"type": "tcp"
},
"id": "ollama",
"metadata": {
"author": "Ollama",
"category": "community",
"icon": "/assets/img/app-icons/ollama.png",
"repo": "https://github.com/ollama/ollama",
"tier": "optional"
},
"name": "Ollama",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 11434,
"host": 11434,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "50Gi"
},
"security": {
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "ollama/ollama"
},
"version": "0.5.4",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/ollama",
"target": "/root/.ollama",
"type": "bind"
}
]
}
},
"version": "2.4"
"version": "latest"
},
"phoenixd": {
"manifest": {
@@ -5030,10 +5004,6 @@
},
"version": "1.0.0"
},
"routstr": {
"image": "source.archipelago-foundation.org/lfg2025/routstr:v0.4.3",
"version": "v0.4.3"
},
"searxng": {
"image": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"manifest": {
@@ -5190,6 +5160,90 @@
},
"tailscale": {
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
"manifest": {
"app": {
"container": {
"entrypoint": [
"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"
],
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
"network": "pasta",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"description": "Zero-config VPN with WireGuard mesh networking.",
"environment": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"health_check": {
"endpoint": "localhost:8240",
"interval": "30s",
"retries": 3,
"timeout": "5s",
"type": "tcp"
},
"id": "tailscale",
"interfaces": {
"main": {
"description": "Tailscale web console",
"name": "Web console",
"path": "/",
"port": 8240,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"author": "Tailscale",
"category": "networking",
"icon": "/assets/img/app-icons/tailscale.webp",
"repo": "https://github.com/tailscale/tailscale",
"tier": "recommended"
},
"name": "Tailscale",
"ports": [
{
"auth": "open",
"auth_rationale": "Tailscale's web console authenticates against the tailnet account for all administrative actions; the node's cookie challenge would be a second, redundant login.",
"bind": "127.0.0.1",
"container": 8240,
"host": 8240,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "1Gi",
"memory_limit": "512Mi"
},
"security": {
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "tailscale/tailscale"
},
"version": "1.78.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/tailscale",
"target": "/var/lib/tailscale",
"type": "bind"
}
]
}
},
"version": "stable"
},
"uptime-kuma": {
@@ -5366,7 +5420,7 @@
}
},
"schema": 1,
"signature": "da5b6b183ac46c062945c27abdc06affb558e805e1ccf67ac0ee17e5e3dd85cc05a0656dd83bdacb1e1d237445145d00995f55e77209e1cbb2b6d8ce47084e0a",
"signature": "03941cb0dd7ec0ce344e4231da40606f1ef24d34a7f1f872622c42bd982846ec622e5161839a68824be4d386fcc68c4958624daf5249897c620a835dc2d11304",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"updated": "2026-08-30"
"updated": "2026-09-01"
}
+19 -18
View File
@@ -1,31 +1,32 @@
{
"changelog": [
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
"**What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.",
"**The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.",
"**Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.",
"**Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.",
"**Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.",
"**Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it."
],
"components": [
{
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
"current_version": "1.8.7-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.5-alpha",
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
"size_bytes": 63934504
"new_version": "1.8.7-alpha",
"sha256": "572accec81e73fbcd5218ddc41f5ec719deda30f104c838398fdfa85a7a276f0",
"size_bytes": 63990960
},
{
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
"new_version": "1.8.5-alpha",
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
"size_bytes": 97657151
"current_version": "1.8.7-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago-frontend-1.8.7-alpha.tar.gz",
"name": "archipelago-frontend-1.8.7-alpha.tar.gz",
"new_version": "1.8.7-alpha",
"sha256": "26b8c12b1b3e6b5c93841f3713037f65d0ea6a52401bd28888eb8d2f5dc483b5",
"size_bytes": 97779181
}
],
"release_date": "2026-08-31",
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
"signature": "79332435d436e7bae1a5b0c12158b02083b681b6818ac90219021f1e39eb61a3e1039ed38ee8089006a0dec862fd92997e44a6c9ccf0be17e91a95cba5de6402",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.5-alpha"
"version": "1.8.7-alpha"
}
@@ -0,0 +1,30 @@
{
"changelog": [
"**SSH over the mesh is now a first-class setting.** Settings gains an \"SSH over mesh\" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit \"I understand\" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.",
"**The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).",
"**App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.",
"**Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse."
],
"components": [
{
"current_version": "1.8.8-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.8-alpha",
"sha256": "96f39b8db6f08386200e1eab91c8444a7758526e6034100c8a33907ff9263530",
"size_bytes": 64175864
},
{
"current_version": "1.8.8-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago-frontend-1.8.8-alpha.tar.gz",
"name": "archipelago-frontend-1.8.8-alpha.tar.gz",
"new_version": "1.8.8-alpha",
"sha256": "7829b67edf8dec27997dd821650ed4d61aea721f802d46a8d47014f4b4246db1",
"size_bytes": 97730549
}
],
"release_date": "2026-09-01",
"signature": "c839cbdcb356a503d87bc17f52b6e5f3a934ae1e72a891f2d21d85366f23debb224a2a40b9124bab50fe95444e01e27711690b1bc50062f40b7ed4f34e078d06",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.8-alpha"
}
-3
View File
@@ -19,13 +19,10 @@ INTERNAL_MANIFEST_IDS = {
"archy-nbxplorer",
"bitcoin-ui",
"core-lightning",
"did-wallet",
"electrs-ui",
"fips-ui",
"lightning-stack",
"lnd-ui",
"mempool-api",
"morphos-server",
"router",
"strfry",
"web5-dwn",
+18 -5
View File
@@ -1,21 +1,22 @@
#!/bin/bash
# Validate releases/manifest.json:
# Validate the live or a pending release manifest:
# - version matches core/archipelago/Cargo.toml
# - changelog contains curated release notes, not raw git log output
# - every component's download_url exists on disk and matches sha256/size
#
# Run on every push from CI, and also locally before publishing a release:
# scripts/check-release-manifest.sh
# scripts/check-release-manifest.sh [path/to/manifest.json]
#
# Exits non-zero on any mismatch so the release process fails loud.
set -eo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="$REPO_ROOT/releases/manifest.json"
MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST"
if [ ! -f "$MANIFEST" ]; then
echo "❌ releases/manifest.json missing"
echo "❌ manifest missing: $MANIFEST"
exit 1
fi
@@ -25,6 +26,18 @@ ok() { echo "✅ $*"; }
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
# A prepared release deliberately leaves the live manifest on the previous
# version. Ordinary pushes are therefore harmless: only the publisher promotes
# the pending manifest after its assets have been uploaded and downloaded back.
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then
PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json"
if [ -f "$PENDING" ]; then
ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending"
MANIFEST="$PENDING"
MANIFEST_VERSION="$CARGO_VERSION"
fi
fi
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
fi
@@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
done
echo
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}"
ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}"
+12 -8
View File
@@ -261,15 +261,19 @@ content = open('$CHANGELOG_FILE').read()
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
m = re.search(pattern, content, re.DOTALL)
if m:
for line in m.group(1).strip().split('\n')[:10]:
for line in m.group(1).splitlines():
line = line.strip()
if line:
print(line)
if not line.startswith('- '):
continue
text = line[2:].strip()
if text.lower().startswith('validation '):
continue
print(text)
" 2>/dev/null || echo "")
if [ -n "$ENTRIES" ]; then
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
import sys, json
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
lines = [l.strip() for l in sys.stdin if l.strip()]
print(json.dumps(lines))
")
fi
@@ -298,7 +302,7 @@ echo ""
cat "$OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Review the manifest above"
echo " 2. Upload artifacts to Gitea release v$VERSION"
echo " 3. Commit manifest.json to releases/manifest.json on main"
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
echo " 1. Review and sign the manifest above"
echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
echo " (it uploads + verifies assets before atomically promoting the manifest)"
+39 -49
View File
@@ -2,7 +2,8 @@
# create-release.sh — Full release automation for Archipelago
#
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
# creates release manifest, and creates git tag.
# creates a pending release manifest, and creates git tag. The live manifest is
# promoted only by publish-release-assets.sh after the assets are verified.
#
# Usage:
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
@@ -30,9 +31,9 @@ for arg in "$@"; do
echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build backend"
echo " 4. Build frontend"
echo " 5. Generate changelog from git log"
echo " 6. Create release manifest"
echo " 7. Commit version bump"
echo " 5. Validate the curated changelog"
echo " 6. Create pending release manifest"
echo " 7. Commit release preparation"
echo " 8. Create git tag v{VERSION}"
echo ""
echo "Options:"
@@ -121,14 +122,13 @@ if $DRY_RUN; then
echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 6. Create release manifest"
echo " 7. Commit: 'chore: release v${VERSION}'"
echo " 5. Validate the curated changelog"
echo " 6. Create pending release manifest (the live manifest stays unchanged)"
echo " 7. Commit: 'chore: prepare release v${VERSION}'"
echo " 8. Tag: v${VERSION}"
echo ""
echo "After this script, you would:"
echo " - Push: git push && git push --tags"
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
echo "After this script, publish only with:"
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
exit 0
fi
@@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
exit 1
fi
echo "[6/8] Creating release manifest..."
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
echo "[6/8] Creating pending release manifest..."
# Never write the fleet-visible path here. A normal `git push main` must not be
# capable of advertising assets which have not been uploaded yet.
PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}"
PENDING_MANIFEST="$PENDING_DIR/manifest.json"
mkdir -p "$PENDING_DIR"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$"
# §A supply-chain: the OTA manifest must carry the release-root signature.
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
@@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
echo " Enter by itself will NOT submit; pasting twice concatenates"
echo " the phrases and fails on word count."
echo "════════════════════════════════════════════════════════════════"
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony sign "$PENDING_MANIFEST"
"$SIGNER" ceremony verify "$PENDING_MANIFEST"
else
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned"
echo " manifest), because nodes read releases/manifest.json from branch main"
echo " and would refuse to auto-apply it."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST"
fi
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6c/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
"$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST"
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
# because publishing is not the first way an unsigned manifest reaches the
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
# (see the verification URLs printed below), so the COMMIT is what exposes
# it, not the publish. publish-release-assets.sh refusing to ship is a
# backstop that arrives one step too late: by then the unsigned manifest is
# already on main and the fleet is already refusing to auto-apply.
#
# This is why every cycle needed a manual catch. The signing block above is
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
# §A supply-chain gate, mirroring publish-release-assets.sh. The pending path
# prevents an ordinary main push from exposing the release, but an unsigned
# manifest is still unpublishable and must never be tagged as ready.
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
# pin on every node. From v1.7.123 onward the new root signs, and nodes
# running .122+ reject anything signed with the old key.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
if ! grep -q '"signature":' "$PENDING_MANIFEST" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then
echo "" >&2
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
echo " Refusing to commit — nodes read this file from branch main and will" >&2
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
echo "Error: the pending manifest is NOT signed by the release root." >&2
echo " Refusing to commit an unpublishable release." >&2
echo "" >&2
echo " Sign it, then re-run this script:" >&2
echo " bash scripts/sign-manifest.sh" >&2
echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2
echo "" >&2
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
exit 1
fi
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
"$SIGNER" ceremony verify "$PENDING_MANIFEST" \
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
echo "[7/8] Committing version bump..."
echo "[7/8] Committing release preparation..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
core/Cargo.lock \
@@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \
neode-ui/package-lock.json \
neode-ui/public/catalog.json \
CHANGELOG.md \
releases/manifest.json \
release-manifest.json \
2>/dev/null || true
# releases/** is ignored because binaries live in Gitea attachments; force-add
# only this small signed pending manifest.
git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json"
# Cargo.lock (rewritten by the release build after the version bump) and
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}"
echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
@@ -319,8 +309,8 @@ echo ""
echo "Artifacts:"
echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json"
echo " - Live manifest: unchanged until assets pass publication verification"
echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}"
echo ""
-11
View File
@@ -73,7 +73,6 @@ SINGLE = {
"vaultwarden": "VAULTWARDEN_IMAGE",
"nextcloud": "NEXTCLOUD_IMAGE",
"searxng": "SEARXNG_IMAGE",
"cryptpad": "CRYPTPAD_IMAGE",
"filebrowser": "FILEBROWSER_IMAGE",
"nginx-proxy-manager": "NPM_IMAGE",
"portainer": "PORTAINER_IMAGE",
@@ -81,9 +80,6 @@ SINGLE = {
"fedimint": "FEDIMINT_IMAGE",
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
"nostr-vpn": "NOSTR_VPN_IMAGE",
"fips": "FIPS_IMAGE",
"routstr": "ROUTSTR_IMAGE",
"adguardhome": "ADGUARDHOME_IMAGE",
}
@@ -100,13 +96,6 @@ STACK = {
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
"immich_redis": "REDIS_IMAGE",
},
"penpot": {
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
"penpot-backend": "PENPOT_BACKEND_IMAGE",
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
},
"mempool": {
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
-11
View File
@@ -54,7 +54,6 @@ NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
# Replaced by CryptPad (single Node.js process, e2e encrypted)
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
# 2.39.1 is what the fleet has actually been running via the moving :latest
@@ -99,13 +98,9 @@ VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
# Nostr
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.10.0"
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
# AI / Routing
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
# Community / Gaming
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.2.11"
@@ -127,12 +122,6 @@ GITEA_IMAGE="docker.io/gitea/gitea:1.23"
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
# Penpot stack
PENPOT_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15"
PENPOT_VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1"
PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
# Custom UI containers (built from docker/ dirs, pushed to registry)
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
+24 -5
View File
@@ -165,16 +165,35 @@ else
sudo rm -rf "$TMPBIN"
fi
# ── Frontend payload present ─────────────────────────────────────────
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
# ── Frontend + companion payload ─────────────────────────────────────
WEB_UI="$MNT/archipelago/web-ui"
if [ -f "$WEB_UI/index.html" ]; then
ok "frontend payload (archipelago/web-ui/index.html)"
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
if [ -f "$WEB_UI/aiui/index.html" ]; then
ok "AIUI included in frontend payload"
else
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
bad "AIUI missing from archipelago/web-ui"
fi
COMPANION_META="$WEB_UI/packages/archipelago-companion.json"
COMPANION_APK="$WEB_UI/packages/archipelago-companion.apk"
if [ -s "$COMPANION_APK" ] && [ "$(jq -r '.versionName // empty' "$COMPANION_META" 2>/dev/null)" = "0.5.28" ] \
&& [ "$(jq -r '.versionCode // empty' "$COMPANION_META" 2>/dev/null)" = "48" ]; then
ok "Companion 0.5.28 (versionCode 48) APK included"
else
bad "Companion 0.5.28 APK/metadata missing or stale"
fi
SETTINGS_BUNDLE="$(grep -rlF "v$EXPECTED_VERSION" "$WEB_UI/assets" 2>/dev/null | head -1)"
if [ -n "$SETTINGS_BUNDLE" ] \
&& grep -qF 'v1.8.0-alpha' "$SETTINGS_BUNDLE" \
&& ! grep -qE 'v1\.[0-7]\.|v1\.2\.0-alpha' "$SETTINGS_BUNDLE"; then
ok "What's New is v1.8.6-first with a v1.8.0 history floor"
else
bad "What's New payload is missing v1.8 history or still contains pre-v1.8 entries"
fi
else
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
bad "no archipelago/web-ui payload on ISO"
fi
echo
+46 -10
View File
@@ -16,14 +16,26 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json"
LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json"
if [ -f "$PENDING_MANIFEST" ]; then
MANIFEST="$PENDING_MANIFEST"
PROMOTE_MANIFEST=1
else
# Backward compatibility for releases prepared before pending manifests.
MANIFEST="$LIVE_MANIFEST"
PROMOTE_MANIFEST=0
fi
fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
[ -f "$MANIFEST" ] || fail "release manifest missing: $MANIFEST"
MANIFEST_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")
[ "$MANIFEST_VERSION" = "$VERSION" ] || fail "requested v$VERSION but $MANIFEST describes v$MANIFEST_VERSION"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh"
"$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST"
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
@@ -32,11 +44,11 @@ fail() { echo "Error: $*" >&2; exit 1; }
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
# v1.7.123 onward.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
grep -q '"signature":' "$MANIFEST" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$MANIFEST" \
|| fail "$MANIFEST is not signed by the release root — run: bash scripts/sign-manifest.sh $MANIFEST"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$MANIFEST" \
|| fail "manifest signature failed cryptographic verification"
fi
@@ -130,12 +142,36 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
# hand during recovery. It fails hard on the first bad asset — the previous
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
# the subshell and let this script march on to "published and verified".
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
# Assets are proven fetchable — only now does the manifest become live.
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
# Assets are proven fetchable — only now may the manifest become live. First
# incorporate concurrent work, then promote in a dedicated commit. Until the
# final push succeeds the remote still serves the previous manifest.
echo "Assets verified. Synchronizing main before manifest promotion..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
if [ "$PROMOTE_MANIFEST" = "1" ]; then
cp "$MANIFEST" "$LIVE_MANIFEST"
cp "$MANIFEST" "$PROJECT_ROOT/release-manifest.json"
git -C "$PROJECT_ROOT" add releases/manifest.json release-manifest.json
git -C "$PROJECT_ROOT" rm -f -- "releases/pending/v${VERSION}/manifest.json"
git -C "$PROJECT_ROOT" commit -m "chore: publish release v${VERSION}"
fi
echo "Publishing verified manifest to main (this makes v${VERSION} live)..."
# A concurrent push can race the fetch above. Merge and retry without ever
# force-pushing; the remote remains on its old, working manifest meanwhile.
for attempt in 1 2 3; do
if git -C "$PROJECT_ROOT" push "$REMOTE" HEAD:main; then
break
fi
[ "$attempt" -lt 3 ] || fail "main advanced repeatedly; assets are safe but manifest was not promoted"
echo "main advanced during publication; merging and retrying..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
done
echo "Release v${VERSION} published and verified on $REMOTE."
+9 -6
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
#
# Run: bash scripts/sign-manifest.sh
# Run: bash scripts/sign-manifest.sh [path/to/manifest.json]
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
#
# Signs releases/manifest.json in place and cryptographically verifies the
# Signs the requested manifest (live by default) and cryptographically verifies the
# result against the pinned release-root anchor. The mnemonic is read from the
# terminal only (never stored, never in shell history, never passed to Claude).
#
@@ -18,7 +18,9 @@
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$REPO/releases/manifest.json"
MANIFEST="${1:-$REPO/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO/$MANIFEST"
[ -f "$MANIFEST" ] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; }
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
# the earlier catalog ceremony). Prefer the repo's release build.
@@ -41,9 +43,10 @@ echo "════════════════════════
echo
if "$BIN" ceremony verify "$MANIFEST"; then
echo "✅ SUCCESS — manifest signed by the pinned release root."
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
echo "✅ SUCCESS — manifest signed by the pinned release root: $MANIFEST"
if [ "$MANIFEST" = "$REPO/releases/manifest.json" ]; then
cp "$MANIFEST" "$REPO/release-manifest.json"
fi
else
echo "❌ Signature did NOT verify against the pinned release-root anchor."
echo " Do NOT commit. Check the mnemonic and re-run."
+118 -28
View File
@@ -11,8 +11,9 @@ a block (from the curated CHANGELOG bullets) and inserts it newest-first.
python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing
Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is
user-facing. Only CHANGELOG versions are managed; older hand-written blocks
(pre-CHANGELOG history) are never touched or removed.
user-facing. The visible history deliberately starts at v1.8.0-alpha; older
blocks are removed so this remains a concise product history rather than an
unbounded archive.
"""
import re
import sys
@@ -25,6 +26,7 @@ MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue"
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
MIN_VISIBLE_VERSION = (1, 8, 0)
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
@@ -47,7 +49,10 @@ def parse_changelog():
if m:
ver, y, mo, d = m.groups()
cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []}
entries.append(cur)
if version_key(ver) >= MIN_VISIBLE_VERSION:
entries.append(cur)
else:
cur = None
continue
if cur is not None and line.startswith("- "):
text = line[2:].strip()
@@ -67,14 +72,82 @@ def undated_versions():
return found
def existing_versions():
text = MODAL.read_text()
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
def ordered_versions():
"""Return generated modal versions in display order (top to bottom)."""
return re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", MODAL.read_text())
def legacy_blocks():
"""Return old hand-written alpha blocks that predate generated markers."""
return re.findall(r"<!-- (alpha\.[^ ]+) -->", MODAL.read_text())
def version_key(version):
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version)
return tuple(map(int, match.groups()))
def sort_modal_blocks(entries):
"""Re-render current release-note blocks newest-first and remove old history."""
lines = MODAL.read_text().splitlines(keepends=True)
# Include the old hand-written `alpha.*` blocks in the replace range so
# normalization can delete them. Previously the checker saw only generated
# vX.Y.Z markers and falsely claimed the v1.8.0 history floor was enforced.
marker = re.compile(r"^\s*<!-- ((?:v\d+\.\d+\.\d+\S*)|(?:alpha\.[^ ]+)) -->\s*$")
blocks = []
for start, line in enumerate(lines):
match = marker.match(line)
if not match:
continue
depth = 0
opened = False
for index in range(start + 1, len(lines)):
for tag in re.findall(r"</?div\b[^>]*>", lines[index]):
if tag.startswith("</"):
depth -= 1
else:
depth += 1
opened = True
if opened and depth == 0:
blocks.append((start, index + 1, match.group(1), lines[start:index + 1]))
break
else:
raise RuntimeError(f"unclosed What's New block for {match.group(1)}")
if not blocks:
return False
for previous, following in zip(blocks, blocks[1:]):
gap = "".join(lines[previous[1]:following[0]])
if gap.strip():
raise RuntimeError("unexpected content between What's New release blocks")
by_version = {entry["ver"]: entry for entry in entries}
retained = [
b for b in blocks
if b[2].startswith("v") and version_key(b[2]) >= MIN_VISIBLE_VERSION
]
sorted_segments = [
render_block(by_version[b[2]]).splitlines(keepends=True)
if b[2] in by_version else b[3]
for b in sorted(retained, key=lambda b: version_key(b[2]), reverse=True)
]
output = lines[:blocks[0][0]]
for segment in sorted_segments:
output.extend(segment)
output.extend(lines[blocks[-1][1]:])
changed = output != lines
if changed:
MODAL.write_text("".join(output))
return changed
def to_html(text):
text = text.replace("`", "") # drop markdown code ticks (plain prose)
return html.escape(text, quote=False) # & < > (Vue template-safe)
text = text.replace("`", "")
escaped = html.escape(text, quote=False) # & < > (Vue template-safe)
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", r"<em>\1</em>", escaped)
return escaped
def render_block(entry):
@@ -113,36 +186,53 @@ def main():
return 1
entries = parse_changelog()
have = existing_versions()
displayed = ordered_versions()
have = set(displayed)
missing = [e for e in entries if e["ver"] not in have]
expected_order = sorted(displayed, key=version_key, reverse=True)
out_of_order = displayed != expected_order
too_old = [v for v in displayed if version_key(v) < MIN_VISIBLE_VERSION]
too_old.extend(legacy_blocks())
if not missing:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present).")
if not missing and not out_of_order and not too_old:
changed = False if check else sort_modal_blocks(entries)
if changed:
print("Re-rendered What's New blocks from the curated changelog.")
else:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present and newest-first).")
return 0
names = ", ".join(e["ver"] for e in missing)
if check:
print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
if missing:
print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
if out_of_order:
print("FAIL: What's New entries are not newest-first; the modal currently "
f"opens at {displayed[0]} instead of {expected_order[0]}", file=sys.stderr)
if too_old:
print("FAIL: What's New contains entries older than the v1.8.0 history floor: "
+ ", ".join(too_old), file=sys.stderr)
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
return 1
# Insert missing blocks newest-first, immediately before the newest existing
# block marker (the first "<!-- v... -->" line in the file).
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
if idx is None:
print("ERROR: could not find an existing version block marker in the modal.",
file=sys.stderr)
return 2
if missing:
# Insert before the first block; the full sort below makes this safe even
# when a historical hand-written block was accidentally left at the top.
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
if idx is None:
print("ERROR: could not find an existing version block marker in the modal.",
file=sys.stderr)
return 2
lines.insert(idx, "".join(render_block(e) for e in missing))
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
# newest-first: sort missing by their order in `entries` (already newest-first)
block_text = "".join(render_block(e) for e in missing)
lines.insert(idx, block_text)
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
if sort_modal_blocks(entries):
print("Normalized What's New blocks (v1.8.0+ only, newest-first).")
return 0
+1 -1
View File
@@ -127,7 +127,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
update:: lnd container::image_versions scanner drift missing_secret
update:: lnd container::image_versions scanner drift missing_secret collision
# ── Stage 4: live node smoke ─────────────────────────────────────────
if [[ $LIVE -eq 1 ]]; then