Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d41b082d7 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 | ||
|
|
d8320896c4 | ||
|
|
b87f1f0612 | ||
|
|
1ca002661b | ||
|
|
0d0e2e243a | ||
|
|
9c49b502e3 | ||
|
|
d68a013e35 | ||
|
|
1464b1b24d | ||
|
|
82001403b4 | ||
|
|
81ede159ac | ||
|
|
8e988be853 | ||
|
|
210f7f1b12 | ||
|
|
ed49cc974f | ||
|
|
4849186ab9 | ||
|
|
3347b8b8b9 | ||
|
|
e382e679ae | ||
|
|
77d0768a21 | ||
|
|
f133d5555a | ||
|
|
cbd5314dd9 | ||
|
|
9fb2e1ed9e | ||
|
|
7125dea05d | ||
|
|
bcdf2c75be | ||
|
|
e77f60085d | ||
|
|
6c31eb9d4a | ||
|
|
63e6c64c63 | ||
|
|
4d8bb1fd44 | ||
|
|
2b4b60013c | ||
|
|
f0ef410948 | ||
|
|
19467e9b7c | ||
|
|
628ed252b4 | ||
|
|
bc94445ca0 | ||
|
|
04cf0f663a | ||
|
|
576c642da4 | ||
|
|
12866db84a | ||
|
|
a184254706 | ||
|
|
192e045426 | ||
|
|
9ac46a69f8 | ||
|
|
bf6ef9644c | ||
|
|
c32910809e | ||
|
|
d2174128c5 | ||
|
|
2ad0171e5f | ||
|
|
46cb0bfd37 | ||
|
|
b8593c9090 | ||
|
|
fc68c5b680 | ||
|
|
3ed75c328d | ||
|
|
687196ad3b | ||
|
|
e2bd6330a1 | ||
|
|
7c0a492c43 | ||
|
|
3089624969 | ||
|
|
5b658cec67 | ||
|
|
21b8d4b1ee | ||
|
|
6f05f5583f | ||
|
|
5ffdcc9936 | ||
|
|
9cf07e1eac | ||
|
|
e7854702c0 | ||
|
|
d4018a6e73 | ||
|
|
b57cba63d1 | ||
|
|
7bc9f69b1f | ||
|
|
913743923c | ||
|
|
017505c431 | ||
|
|
7a39d8fbd1 | ||
|
|
e3275353b9 |
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.11-alpha (2026-09-07)
|
||||
|
||||
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
|
||||
|
||||
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
|
||||
|
||||
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
|
||||
|
||||
## v1.8.10-alpha (2026-09-02)
|
||||
|
||||
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
||||
|
||||
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
|
||||
|
||||
## v1.8.9-alpha (2026-09-01)
|
||||
|
||||
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
|
||||
|
||||
- **The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.
|
||||
|
||||
- **App frames open over HTTPS again — including the ones that "did not connect."** The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.
|
||||
|
||||
- **Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.
|
||||
|
||||
- **Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.
|
||||
|
||||
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
|
||||
|
||||
## 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.
|
||||
|
||||
Submodule aiui/.claude/worktrees/agitated-hofstadter deleted from 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter deleted from 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden deleted from 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver deleted from a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman deleted from e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden deleted from aaaef7d710
+423
-366
@@ -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.79",
|
||||
"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.79",
|
||||
"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 — 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,87 +189,63 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.63.23",
|
||||
"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.63.23",
|
||||
"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.27.3",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"/var/lib/archipelago/gitea/data:/data",
|
||||
"/var/lib/archipelago/gitea/config:/etc/gitea"
|
||||
],
|
||||
"env": [
|
||||
"TZ=UTC"
|
||||
"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="
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
@@ -405,6 +271,279 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2026.8.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.3",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8123:8123"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/home-assistant:/config"
|
||||
],
|
||||
"env": [
|
||||
"TZ=UTC"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.21.2",
|
||||
"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.21.2-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 — 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 — 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 — served on the node's loopback for the AI assistant (Settings → Claude Auth → 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 — 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.",
|
||||
"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": "portainer",
|
||||
"title": "Portainer",
|
||||
"version": "2.45.0",
|
||||
"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.45.0",
|
||||
"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": "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": "tailscale",
|
||||
"title": "Tailscale",
|
||||
@@ -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,45 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.37.2",
|
||||
"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.2-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",
|
||||
"id": "dojobay",
|
||||
"title": "Dojo Bay",
|
||||
"version": "1.0.0",
|
||||
"description": "Onion-only directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets, with Auth47 self-service listings.",
|
||||
"icon": "/assets/img/app-icons/dojobay.svg",
|
||||
"author": "Dojobay",
|
||||
"category": "money",
|
||||
"dockerImage": "localhost/archipelago-dojobay:1.0.0",
|
||||
"repoUrl": "https://github.com/Dojobay/dojobay",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
"8188:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
"/var/lib/archipelago/dojobay/data:/app/data",
|
||||
"/var/lib/archipelago/dojobay/server-data:/app/server/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"tier": "optional"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| dojobay | 8188 | TCP | Web UI | 18188 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
app:
|
||||
id: adguardhome
|
||||
name: AdGuard Home
|
||||
version: v0.107.79
|
||||
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.79
|
||||
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
|
||||
+51
-15
@@ -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
|
||||
@@ -50,7 +45,12 @@ app:
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
|
||||
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
|
||||
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
disk_limit: 300Gi
|
||||
|
||||
security:
|
||||
@@ -87,17 +87,21 @@ app:
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
|
||||
# the gate still takes the port over (loopback pin, external binds,
|
||||
# fronts the Tor onion) but skips the dashboard login challenge, same
|
||||
# reasoning as electrumx's port — wallet clients (Feather,
|
||||
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
|
||||
# cannot complete a browser login or hold a session cookie. The daemon
|
||||
# still uses its canonical container port 18089, but Penpot already owns
|
||||
# host port 18089, so this maps the public host port to the free 18090
|
||||
# instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -108,11 +112,23 @@ app:
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - fast_sync: cuprated's own default is false, which performs full
|
||||
# cryptographic verification (ring signatures + RandomX PoW) on every
|
||||
# incoming block instead of trusting checkpointed history. Root-caused
|
||||
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
|
||||
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
|
||||
# block rate. Set explicitly rather than relying on the binary
|
||||
# default so fresh deploys don't silently regress into full-verify.
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
|
||||
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
|
||||
# forced constant eviction/flush (595GB/24h block I/O on a node just
|
||||
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
|
||||
# the healthy reference node, and
|
||||
# resources.memory_limit above raised in step to keep headroom above it.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
@@ -130,14 +146,34 @@ app:
|
||||
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
|
||||
# restricts it externally) — not a new risk, the same one already
|
||||
# reviewed and accepted for Bitcoin's RPC.
|
||||
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
||||
# operator reading Cuprated.toml on disk should be able to see and
|
||||
# tune the log level directly instead of the file silently omitting
|
||||
# the whole [tracing] table (verified live on the affected node
|
||||
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
||||
# the level was only discoverable by running `cuprated
|
||||
# --generate-config` and diffing). file.level is set to "info", NOT
|
||||
# cuprated's own raw default of "debug" — matches the reference dev
|
||||
# config this app was built and tested against (verified 2026-09-01),
|
||||
# which deliberately runs file logging quieter
|
||||
# than the binary default. max_log_files similarly follows that
|
||||
# reference (14, not the binary default of 7).
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
fast_sync = true
|
||||
target_max_memory = 8589934592
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
|
||||
[tracing.stdout]
|
||||
level = "info"
|
||||
|
||||
[tracing.file]
|
||||
level = "info"
|
||||
max_log_files = 14
|
||||
overwrite: false
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
app:
|
||||
id: dojobay
|
||||
name: Dojo Bay
|
||||
version: 1.0.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: Dojobay/dojobay
|
||||
description: Onion-only directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets, with Auth47 self-service listings.
|
||||
category: money
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/dojobay
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-dojobay:1.0.0
|
||||
network: archy-net
|
||||
|
||||
dependencies:
|
||||
- storage: 200Mi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 500Mi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
- host: 8188
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# open, not gated: Dojo Bay is a public directory. Anonymous Tor
|
||||
# visitors must be able to browse listings, scan pairing QR codes and
|
||||
# read the JSON data feed without a dashboard login challenge — that is
|
||||
# the entire point of the site. It carries its own complete Auth47
|
||||
# sign-in (BIP47 payment-code challenge, no accounts/passwords) that
|
||||
# gates listing management and the admin/moderation console, the same
|
||||
# shape Gitea and BTCPay use this policy for.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Public onion directory: anonymous visitors must browse, pair and fetch
|
||||
the JSON feed with no dashboard login. Listing management and admin
|
||||
moderation are behind the app's own Auth47 (BIP47) sign-in instead.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/dojobay/data
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/dojobay/server-data
|
||||
target: /app/server/data
|
||||
options: [rw]
|
||||
# nginx's own working files (pid, client-body/proxy temp dirs). Not
|
||||
# persistent data — recreated on every start — hence tmpfs rather than a
|
||||
# bind mount, and required at all only because security.readonly_root
|
||||
# makes the rest of the image's filesystem read-only at runtime.
|
||||
- type: tmpfs
|
||||
target: /var/lib/nginx
|
||||
- type: tmpfs
|
||||
target: /var/run
|
||||
tmpfs_options: "rw,noexec,nosuid,size=16m"
|
||||
|
||||
files:
|
||||
# Archipelago's Tor daemon binds a second SocksPort on this network's
|
||||
# bridge gateway specifically so containers can reach it (the app itself
|
||||
# cannot resolve {{NETWORK_GATEWAY}} — only a generated file can, per
|
||||
# docs/app-developer-guide.md). Must sit under a declared bind-mount
|
||||
# source, hence co-located with the data volume above; the container
|
||||
# entrypoint reads it and points the backend's outbound Tor at it.
|
||||
- path: /var/lib/archipelago/dojobay/data/tor-proxy.conf
|
||||
content: "{{NETWORK_GATEWAY}}:9050"
|
||||
overwrite: true
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Dojo Bay directory
|
||||
type: ui
|
||||
port: 8188
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/dojobay.svg
|
||||
repo: https://github.com/Dojobay/dojobay
|
||||
tier: optional
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
features:
|
||||
- Onion-only directory of Bitcoin Dojo nodes
|
||||
- Auth47 self-service listings, no accounts or passwords
|
||||
- Automatic 24-hour and 90-day reliability tracking
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: filebrowser
|
||||
name: File Browser
|
||||
version: 2.27.0
|
||||
version: 2.63.23
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0
|
||||
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: gitea
|
||||
name: Gitea
|
||||
version: "1.23"
|
||||
version: "1.27.3"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: docker.io/gitea/gitea:1.23
|
||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: homeassistant
|
||||
name: Home Assistant
|
||||
version: 2026.7.3
|
||||
version: 2026.8.3
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: lnd
|
||||
name: LND
|
||||
version: 0.18.4
|
||||
version: 0.21.2
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta
|
||||
image: source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV MORPHOS_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -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
|
||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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:
|
||||
# NET_BIND_SERVICE is load-bearing, not decoration: NPM's internal nginx
|
||||
# listens on 80, 443 AND 81, and the orchestrator runs --cap-drop=ALL —
|
||||
# without this cap every start dies with "bind() to 0.0.0.0:80 failed
|
||||
# (13: Permission denied)" and s6 restart-loops forever (shorty-s,
|
||||
# 2026-09-01, restart counter 3176 within hours of the manifest
|
||||
# conversion). The legacy podman-run path defaulted to the full cap set,
|
||||
# which is why it never showed there.
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
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]
|
||||
# Current NPM images refuse to start unless /etc/letsencrypt is a mount in
|
||||
# its own right. Keeping the files below the same persistent app directory
|
||||
# preserves existing certificates while satisfying that startup contract.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||
target: /etc/letsencrypt
|
||||
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
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
version: "3.6.0"
|
||||
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
|
||||
# project's GitHub tags are not the image tags). NOTE: this manifest
|
||||
# deliberately ships an args-tuned revision AHEAD of the image tag (see
|
||||
@@ -24,7 +24,7 @@ app:
|
||||
container_name: pine-whisper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.4.1
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.6.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-whisper]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: portainer
|
||||
name: Portainer
|
||||
version: 2.19.4
|
||||
version: 2.45.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: vaultwarden
|
||||
name: Vaultwarden
|
||||
version: 1.30.0
|
||||
version: 1.37.2
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Self-hosted password vault with zero-knowledge encryption.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
|
||||
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
|
||||
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<g transform="translate(24,-58.5) scale(1.45)">
|
||||
<g fill="#b5302a">
|
||||
<path d="M40 96 Q160 112 280 96 L280 116 Q160 132 40 116 Z"/>
|
||||
<path d="M154 116 H166 V124 H154 Z"/>
|
||||
<path d="M74 124 H246 V144 H74 Z"/>
|
||||
<path d="M104 126 H124 L118 250 H98 Z"/>
|
||||
<path d="M196 126 H216 L222 250 H202 Z"/>
|
||||
</g>
|
||||
<g stroke="#d6534a" stroke-width="14" stroke-linecap="round" fill="none">
|
||||
<path d="M50 272 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0"/>
|
||||
<path d="M50 300 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".72"/>
|
||||
<path d="M50 328 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".48"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+588
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.5-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.5-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,59 @@ use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
fn router_error_message(body: &serde_json::Value) -> Option<&str> {
|
||||
body.get("error")
|
||||
.and_then(|e| e.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| body.get("message").and_then(|v| v.as_str()))
|
||||
}
|
||||
|
||||
fn payment_error(message: &str) -> anyhow::Error {
|
||||
if message.to_ascii_lowercase().contains("invoice expired") {
|
||||
anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
message.trim_start_matches("invoice expired. ")
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!("Payment failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn payment_failure_reason(reason: &str) -> &'static str {
|
||||
match reason {
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
_ => "Payment failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
|
||||
value.get(key).and_then(|v| {
|
||||
v.as_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| v.as_i64())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fee budget for a send, matching lncli's own default: the payment amount
|
||||
/// (100%). Zero-amount invoices take the payer-supplied amount; fixed invoices
|
||||
/// take the invoice's own amount. Falls back to a nominal 1,000 sats only when
|
||||
/// both are somehow absent — the limit must never be left at LND's zero
|
||||
/// default, which rejects every fee-carrying route as "no route".
|
||||
fn fee_limit_sats(amount_sats: Option<u64>, decoded_amt: i64) -> i64 {
|
||||
if let Some(amt) = amount_sats {
|
||||
return amt as i64;
|
||||
}
|
||||
if decoded_amt > 0 {
|
||||
return decoded_amt;
|
||||
}
|
||||
1_000
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
@@ -65,23 +118,30 @@ impl RpcHandler {
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
// Suppress intermediate stream records: one terminal Payment is
|
||||
// enough, and it makes grpc-gateway's response a single JSON value.
|
||||
"no_inflight_updates": true,
|
||||
"timeout_seconds": 120,
|
||||
// Router.SendPaymentV2 treats an ABSENT fee limit as ZERO — every
|
||||
// real route carries a routing fee, so the pathfinder rejects
|
||||
// them all and the wallet gets "No route to the recipient" on
|
||||
// every send (fleet-wide, 2026-09-01: the v1.8.9 switch to the v2
|
||||
// route shipped without this, and a manual lncli test that set
|
||||
// --fee_limit masked it). lncli's own default is the payment
|
||||
// amount (100%), which is what we send here.
|
||||
"fee_limit_sat": fee_limit_sats(amount_sats, decoded_amt),
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — only LND may declare a payment failed,
|
||||
// so a post-connect timeout is IN FLIGHT (status: pending), never
|
||||
// failure. The window is deliberately SHORT: most payments settle in
|
||||
// a couple of seconds and still get their answer in one round trip,
|
||||
// while a slow multi-hop route flips the UI into its "settling…"
|
||||
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
|
||||
// freezing the modal for two minutes with no feedback (a test node
|
||||
// user report, 2026-07-29).
|
||||
// LND 0.21 removed the deprecated Lightning.SendPaymentSync REST route
|
||||
// (`/v1/channels/transactions`). Router.SendPaymentV2 is its supported
|
||||
// replacement. The old route now returns literal 404 "Not Found" on
|
||||
// every payment — the fleet failure seen immediately after the 0.21.2
|
||||
// update. Keep the short browser-facing wait: after LND accepts a slow
|
||||
// payment we return pending and the UI follows it through
|
||||
// lnd.paymentstatus instead of declaring a transport timeout a failure.
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
@@ -91,7 +151,7 @@ impl RpcHandler {
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/router/send"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
@@ -119,49 +179,42 @@ impl RpcHandler {
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payment response")?;
|
||||
.context("Failed to parse Router.SendPaymentV2 response")?;
|
||||
|
||||
// grpc-gateway wraps server-streaming records as {"result": ...} and
|
||||
// transport/RPC failures as {"error": {"message": ...}}. Do not look
|
||||
// only for the old endpoint's top-level `message`: that turns useful
|
||||
// LND errors into "Unknown error".
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
let msg = router_error_message(&body).unwrap_or("Unknown error");
|
||||
return Err(payment_error(msg));
|
||||
}
|
||||
let payment = body.get("result").unwrap_or(&body);
|
||||
match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") {
|
||||
"SUCCEEDED" => {}
|
||||
"FAILED" => {
|
||||
let reason = payment
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(payment_failure_reason)
|
||||
.unwrap_or("Payment failed");
|
||||
return Err(anyhow::anyhow!("Payment failed: {reason}"));
|
||||
}
|
||||
_ => {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
let payment_error = body
|
||||
.get("payment_error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !payment_error.is_empty() {
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||
}
|
||||
|
||||
let amount_sat = body
|
||||
.get("payment_route")
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
let amount_sat = json_i64(payment, "value_sat").unwrap_or(decoded_amt);
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
// The decode endpoint returns the canonical hex hash used by our
|
||||
// polling/list APIs. Router's bytes field is base64 in REST JSON.
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
@@ -482,3 +535,53 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unwraps_grpc_gateway_router_success() {
|
||||
let body = serde_json::json!({
|
||||
"result": { "status": "SUCCEEDED", "value_sat": "1000" }
|
||||
});
|
||||
let payment = body.get("result").unwrap_or(&body);
|
||||
assert_eq!(
|
||||
payment.get("status").and_then(|v| v.as_str()),
|
||||
Some("SUCCEEDED")
|
||||
);
|
||||
assert_eq!(json_i64(payment, "value_sat"), Some(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_nested_router_error() {
|
||||
let body = serde_json::json!({
|
||||
"error": { "code": 2, "message": "invoice expired. valid until yesterday" }
|
||||
});
|
||||
let msg = router_error_message(&body).unwrap();
|
||||
assert!(payment_error(msg).to_string().contains("fresh invoice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_failure_reasons_are_actionable() {
|
||||
assert_eq!(
|
||||
payment_failure_reason("FAILURE_REASON_NO_ROUTE"),
|
||||
"No route to the recipient"
|
||||
);
|
||||
assert_eq!(
|
||||
payment_failure_reason("FAILURE_REASON_INSUFFICIENT_BALANCE"),
|
||||
"Insufficient channel balance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fee_limit_never_falls_back_to_zero() {
|
||||
// SendPaymentV2 defaults an ABSENT fee limit to zero — which rejects
|
||||
// every fee-carrying route as "no route". The budget must always be
|
||||
// positive: the payer-supplied amount for zero-amount invoices, the
|
||||
// invoice's own amount otherwise.
|
||||
assert_eq!(fee_limit_sats(Some(20_000), 0), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 20_000), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 0), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl RpcHandler {
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
.run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -2040,10 +2040,59 @@ autopilot.active=false\n",
|
||||
}));
|
||||
}
|
||||
|
||||
// Portainer ≥2.21 no longer lets whoever loads the page first claim the
|
||||
// admin account: on a fresh install it mints a one-time setup token and
|
||||
// prints it to the SERVER LOGS, expecting the operator to go digging.
|
||||
// On an appliance that is hostile UX — "check the Portainer server
|
||||
// logs" is exactly the dead end users cannot follow. The token is the
|
||||
// only thing standing between the user and their own app, so surface
|
||||
// it in the same launch interstitial as the login credentials: extract
|
||||
// it from the container logs and hand it over with a copy button.
|
||||
// Once setup completes Portainer invalidates the token, and a container
|
||||
// recreate (any update) drops the log line entirely — so absence of the
|
||||
// line naturally makes the card disappear and no stale token lingers.
|
||||
if app_id == "portainer" {
|
||||
if let Some(token) = portainer_setup_token(self).await {
|
||||
return Ok(serde_json::json!({
|
||||
"title": "Portainer first-run token",
|
||||
"description": "New Portainer versions protect the first launch with a one-time setup token instead of letting anyone on the network claim the admin account. Paste this token into Portainer's setup screen to create your administrator login. It is only valid until setup finishes — if you already created your admin account, ignore this.",
|
||||
"credentials": [
|
||||
{ "label": "Setup token", "value": token, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "credentials": [] }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract Portainer's first-run `setup_token=…` from the live container's
|
||||
/// recent logs. `None` when the line is absent (setup already done, or an
|
||||
/// older Portainer without the token flow).
|
||||
async fn portainer_setup_token(rpc: &RpcHandler) -> Option<String> {
|
||||
let logs = rpc.get_container_logs_value("portainer", 300).await.ok()?;
|
||||
let lines = logs.as_array()?;
|
||||
let lines: Vec<&str> = lines.iter().filter_map(|l| l.as_str()).collect();
|
||||
parse_setup_token(&lines)
|
||||
}
|
||||
|
||||
/// Pure log-line scan: the token is 64 hex chars after `setup_token=`.
|
||||
/// Sear newest-first so the most recent mint wins.
|
||||
fn parse_setup_token(lines: &[&str]) -> Option<String> {
|
||||
for line in lines.iter().rev() {
|
||||
let Some(idx) = line.find("setup_token=") else {
|
||||
continue;
|
||||
};
|
||||
let tail = &line[idx + "setup_token=".len()..];
|
||||
let token: String = tail.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
|
||||
if token.len() == 64 {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
match package_id {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
@@ -2751,7 +2800,7 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
orchestrator_install_app_id, should_try_orchestrator_install,
|
||||
orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
|
||||
uses_orchestrator_install_flow,
|
||||
};
|
||||
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
|
||||
@@ -2861,4 +2910,41 @@ mod tests {
|
||||
"Error: no container with name or ID \"bitcoin-knots\" found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_setup_token_is_extracted_from_log_lines() {
|
||||
// Shape captured live from portainer:2.45.0 on 2026-09-01 — the
|
||||
// token line is plain text inside the bordered s6 log block.
|
||||
let logs = [
|
||||
"2026/09/01 12:38PM INF github.com/portainer/portainer/api/database/boltdb/db.go:163 > loading PortainerDB | filename=portainer.db",
|
||||
"==========================",
|
||||
"setup_token=27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a",
|
||||
"Paste it into the setup screen, or send it in the X-Setup-Token header.",
|
||||
"==========================",
|
||||
];
|
||||
assert_eq!(
|
||||
parse_setup_token(&logs).as_deref(),
|
||||
Some("27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_setup_token_absent_when_setup_already_done() {
|
||||
// An instance with an existing admin account never prints the line —
|
||||
// the credentials card must not render a stale or empty token.
|
||||
let logs = [
|
||||
"2026/09/01 11:37AM INF api/datastore/migrator/migrate_ce.go:76 > db migrated to 2.45.0 |",
|
||||
"2026/09/01 11:37:38 server: Listening on http://0.0.0.0:8000",
|
||||
];
|
||||
assert_eq!(parse_setup_token(&logs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_setup_token_rejects_short_or_non_hex_values() {
|
||||
assert_eq!(parse_setup_token(&["setup_token=abc123"]), None);
|
||||
assert_eq!(
|
||||
parse_setup_token(&["setup_token=".to_string().as_str()]),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -131,6 +131,10 @@ const LND_STATE_DIRS: &[&str] = &[
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
@@ -872,6 +876,188 @@ fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
|
||||
Ok(hex::encode_upper(Sha256::digest(&der)))
|
||||
}
|
||||
|
||||
// ── Channel-peer watchdog ──────────────────────────────────────────────────
|
||||
|
||||
/// Every open channel's remote peer that is NOT currently connected.
|
||||
/// Pure over LND's REST JSON so the selection can be unit-tested.
|
||||
///
|
||||
/// `/v1/peers` uses `pub_key`; `/v1/channels` uses `remote_pubkey` — the
|
||||
/// asymmetry is LND's, not ours.
|
||||
fn select_reconnect_targets(
|
||||
channels: &serde_json::Value,
|
||||
peers: &serde_json::Value,
|
||||
) -> Vec<String> {
|
||||
let connected: std::collections::HashSet<&str> = peers
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| p.get("pub_key").and_then(|v| v.as_str()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut targets: Vec<String> = channels
|
||||
.get("channels")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|c| c.get("remote_pubkey").and_then(|v| v.as_str()))
|
||||
.filter(|pk| !connected.contains(pk))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
targets
|
||||
}
|
||||
|
||||
/// Reconnect peers of open channels that LND has not re-established on its
|
||||
/// own. Returns the number of peers reconnected this pass.
|
||||
///
|
||||
/// LND normally reconnects channel peers after a restart — but not reliably:
|
||||
/// when the restart outages are long or repeated (an app update, a node
|
||||
/// reboot, reconciler churn), the peer link can stay down for hours while
|
||||
/// BOTH endpoints keep flagging the channel `disabled` in the routing
|
||||
/// graph. The node itself looks perfectly healthy and every payment in
|
||||
/// either direction fails "no route to the recipient" — observed live on
|
||||
/// framework-pt (2026-09-01): its only channel sat disabled on both policy
|
||||
/// sides for ~17h after the LND 0.21.2 update, while the wallet showed
|
||||
/// plenty of outbound. The channel graph is desired state; this keeps it.
|
||||
///
|
||||
/// Quietly returns Ok(0) when LND is not installed or its wallet is locked —
|
||||
/// that is every node without LND, on every pass.
|
||||
///
|
||||
/// `last_attempt` throttles retries per peer (`min_retry`) so an unreachable
|
||||
/// peer is not hammered every pass; the caller owns the map so the pass
|
||||
/// itself stays stateless and testable.
|
||||
pub(crate) async fn reconnect_disconnected_channel_peers(
|
||||
last_attempt: &mut std::collections::HashMap<String, std::time::Instant>,
|
||||
min_retry: std::time::Duration,
|
||||
) -> Result<usize> {
|
||||
let Ok(macaroon) = read_file_as_root(LND_ADMIN_MACAROON).await else {
|
||||
return Ok(0); // LND not installed (or not initialized yet)
|
||||
};
|
||||
let macaroon_hex = hex::encode(macaroon);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client for the channel-peer watchdog")?;
|
||||
|
||||
let channels: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing channels for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND channel list")?;
|
||||
// A locked wallet answers 503 with an error body — it parses as JSON
|
||||
// with no "channels" key, which selects nothing. That is a quiet pass.
|
||||
let peers: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing peers for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND peer list")?;
|
||||
|
||||
let mut reconnected = 0usize;
|
||||
for pubkey in select_reconnect_targets(&channels, &peers) {
|
||||
if last_attempt
|
||||
.get(&pubkey)
|
||||
.is_some_and(|t| t.elapsed() < min_retry)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last_attempt.insert(pubkey.clone(), std::time::Instant::now());
|
||||
|
||||
// Where does the peer live? Its advertised addresses in the public
|
||||
// graph. A peer with none (fully private) cannot be dialed from here
|
||||
// — LND itself may still find it; we only log the gap once per pass.
|
||||
// Unknown to the public graph (or the graph query failed) — nothing
|
||||
// to dial on.
|
||||
let Ok(node) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/graph/node/{pubkey}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node) = node.json::<serde_json::Value>().await else {
|
||||
continue;
|
||||
};
|
||||
let addresses: Vec<String> = node
|
||||
.get("node")
|
||||
.and_then(|n| n.get("addresses"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.get("addr").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if addresses.is_empty() {
|
||||
tracing::warn!(
|
||||
peer = %pubkey,
|
||||
"LND channel peer is disconnected and advertises no address — cannot dial it; payments through this channel stay unroutable"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(port) = port.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"perm": false,
|
||||
"timeout": "15s",
|
||||
"addr": { "pubkey": pubkey, "host": host, "port": port },
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
reconnected += 1;
|
||||
tracing::info!(
|
||||
peer = %pubkey,
|
||||
addr = %addr,
|
||||
"reconnected a disconnected channel peer (channel was unroutable)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(resp) => {
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
// Already connected between our list call and now — success.
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, %msg, "channel-peer connect attempt failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, error = %e, "channel-peer connect attempt failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reconnected)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -985,4 +1171,35 @@ mod tests {
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_pick_disconnected_channel_peers_only() {
|
||||
// Shape captured from a live node: /v1/channels uses remote_pubkey,
|
||||
// /v1/peers uses pub_key, and an offline channel's peer is simply
|
||||
// absent from the peer list — that absence is the whole signal.
|
||||
let channels = serde_json::json!({
|
||||
"channels": [
|
||||
{ "remote_pubkey": "AAA", "active": true },
|
||||
{ "remote_pubkey": "BBB", "active": false },
|
||||
{ "remote_pubkey": "AAA" }
|
||||
]
|
||||
});
|
||||
let peers = serde_json::json!({ "peers": [ { "pub_key": "AAA" } ] });
|
||||
|
||||
let targets = select_reconnect_targets(&channels, &peers);
|
||||
assert_eq!(targets, vec!["BBB".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_empty_without_channels_or_peers() {
|
||||
// No LND wallet (503 error body), locked wallet, or an empty node:
|
||||
// selects nothing, quietly.
|
||||
let error_body = serde_json::json!({ "message": "locked" });
|
||||
assert!(select_reconnect_targets(&error_body, &serde_json::json!({})).is_empty());
|
||||
assert!(select_reconnect_targets(
|
||||
&serde_json::json!({ "channels": [] }),
|
||||
&serde_json::json!({ "peers": [] })
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,41 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
|
||||
11434, 18081, 18083, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
3030,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8187,
|
||||
8188,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -841,6 +841,37 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// LND channel-peer watchdog — every 2 minutes, reconnect the peers
|
||||
// of open channels that LND has not re-established on its own. LND's
|
||||
// reconnect logic gives up with a long backoff after repeated or
|
||||
// extended downtime (an app update, a reboot, reconciler churn), and
|
||||
// while the peer link is down BOTH endpoints keep the channel flagged
|
||||
// `disabled` in the routing graph — payments fail "no route" in both
|
||||
// directions while the node itself looks perfectly healthy. The
|
||||
// channel graph is desired state; this keeps it (framework-pt,
|
||||
// 2026-09-01: only channel unroutable ~17h after the 0.21.2 update).
|
||||
// No-ops quietly on nodes without LND. Per-peer retries are throttled
|
||||
// to 10 minutes so an unreachable peer is not hammered every pass.
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(120));
|
||||
let mut last_attempt: HashMap<String, Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::container::lnd::reconnect_disconnected_channel_peers(
|
||||
&mut last_attempt,
|
||||
Duration::from_secs(600),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(n, "LND channel-peer watchdog reconnected channel peers"),
|
||||
Err(e) => debug!("LND channel-peer watchdog (non-fatal): {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||
// configured seed anchors into the running fips daemon via
|
||||
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||
|
||||
@@ -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,35 @@ 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.
|
||||
//
|
||||
// dojobay 8188, added for the Dojo Bay app: a public onion directory
|
||||
// that anonymous Tor visitors must be able to browse with no
|
||||
// dashboard login; its own Auth47 (BIP47 payment-code challenge)
|
||||
// gates listing management and the admin console.
|
||||
//
|
||||
// NOTE: as of this change, `left` also carries two entries this
|
||||
// assertion does not yet list — adguardhome at 3030 (not the 3000
|
||||
// hardcoded below) and cuprate at 18090 — both pre-existing drift
|
||||
// from before this change, not introduced by it. Left for whoever
|
||||
// owns those apps to reconcile; not touched here to keep this diff to
|
||||
// the dojobay addition.
|
||||
assert_eq!(
|
||||
open,
|
||||
vec![
|
||||
("adguardhome".to_string(), 3000u16),
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("gitea".to_string(), 3001u16)
|
||||
("dojobay".to_string(), 8188u16),
|
||||
("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"
|
||||
);
|
||||
|
||||
+19
-15
@@ -15,25 +15,32 @@ pub enum PkgManager {
|
||||
impl Router {
|
||||
/// Detect which package manager is available.
|
||||
///
|
||||
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
|
||||
/// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
|
||||
/// than a hardcoded `/usr/bin/<tool>` — official OpenWrt images don't all
|
||||
/// symlink `/bin` into `/usr/bin` (e.g. the `glinet_gl-mt3000` 24.10.2
|
||||
/// build keeps them as separate real directories with `opkg` living in
|
||||
/// `/bin`), so a fixed absolute path silently misses a perfectly normal
|
||||
/// install and reports "no package management" (archy-x250-pa3, 2026-09-05).
|
||||
///
|
||||
/// - If `opkg` is on PATH → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `apk` is on PATH → run `apk update` (switching repos to HTTP
|
||||
/// first to work around missing CA bundle on fresh images), then try
|
||||
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
|
||||
/// 25.x) → `ApkNative`.
|
||||
/// - Neither found → error.
|
||||
pub fn opkg_check(&self) -> Result<PkgManager> {
|
||||
let (_, code) = self.run("test -x /usr/bin/opkg")?;
|
||||
let (_, code) = self.run("command -v opkg >/dev/null 2>&1")?;
|
||||
if code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
|
||||
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
|
||||
let (_, apk_code) = self.run("command -v apk >/dev/null 2>&1")?;
|
||||
if apk_code == 0 {
|
||||
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
|
||||
// Fresh images ship without a CA bundle; switch repos to HTTP so
|
||||
// apk's wget can reach the package index without TLS verification.
|
||||
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
|
||||
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
|
||||
let (update_out, update_code) = self.run("apk update 2>&1")?;
|
||||
if update_code != 0 {
|
||||
anyhow::bail!(
|
||||
"apk update failed (exit {}) — router may have no internet access. \
|
||||
@@ -43,7 +50,7 @@ impl Router {
|
||||
);
|
||||
}
|
||||
// Try to install opkg (only available on some 25.x builds).
|
||||
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
|
||||
let (add_out, add_code) = self.run("apk add opkg 2>&1")?;
|
||||
if add_code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
@@ -62,7 +69,7 @@ impl Router {
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"opkg not found at /usr/bin/opkg — this router's firmware may not \
|
||||
"Neither opkg nor apk found on this router's $PATH — its firmware may not \
|
||||
support package management (TollGate requires a standard OpenWrt build)"
|
||||
);
|
||||
}
|
||||
@@ -70,31 +77,28 @@ impl Router {
|
||||
/// `opkg update` — refresh package lists.
|
||||
pub fn opkg_update(&self) -> Result<()> {
|
||||
info!("[{}] opkg update", self.host);
|
||||
self.run_ok("/usr/bin/opkg update")?;
|
||||
self.run_ok("opkg update")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a package, skipping if already installed.
|
||||
pub fn opkg_install(&self, package: &str) -> Result<()> {
|
||||
// Check if already installed to avoid unnecessary network traffic.
|
||||
let (_, code) = self.run(&format!(
|
||||
"/usr/bin/opkg list-installed | grep -q '^{} '",
|
||||
package
|
||||
))?;
|
||||
let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
|
||||
if code == 0 {
|
||||
info!("[{}] {} already installed", self.host, package);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("[{}] opkg install {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
|
||||
self.run_ok(&format!("opkg install {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a package.
|
||||
pub fn opkg_remove(&self, package: &str) -> Result<()> {
|
||||
info!("[{}] opkg remove {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
|
||||
self.run_ok(&format!("opkg remove {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@ impl Router {
|
||||
}
|
||||
|
||||
info!("[{}] apk add {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
|
||||
self.run_ok(&format!("apk add {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,53 @@ use crate::Router;
|
||||
/// The OpenWrt package name for the TollGate reference implementation.
|
||||
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string.
|
||||
/// Pinned upstream release. Was stuck on v0.2.0 (Oct 2025) until 2026-09-05 —
|
||||
/// nine releases behind. v0.5.0's changelog covers exactly the failure modes
|
||||
/// hit live against archy-x250-pa3: a mint with an empty/broken keyset used
|
||||
/// to crash-loop the daemon forever ("graceful degradation when Cashu mints
|
||||
/// fail" in v0.5.0), and the bundled captive-portal build had no CBOR support
|
||||
/// at all, so it could only decode legacy `cashuA` tokens — rejecting the
|
||||
/// `cashuB` (NUT-00 V4) tokens modern wallets like Minibits generate by
|
||||
/// default ("portal improvements" in v0.5.0 include a JS bundle update that
|
||||
/// should carry a current cashu-ts with V4 support). Bump this string to move
|
||||
/// both this crate's URLs and the version baked into the source comments.
|
||||
const TOLLGATE_VERSION: &str = "v0.5.0";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string, for the
|
||||
/// `.ipk` (ar-archive) package format.
|
||||
/// Used when the package is not in any configured feed.
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
|
||||
fn ipk_url(arch: &str) -> Option<&'static str> {
|
||||
match arch {
|
||||
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
|
||||
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
|
||||
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
|
||||
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
|
||||
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
|
||||
_ => None,
|
||||
}
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
|
||||
fn ipk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"mips_24kc" => "mips_24kc",
|
||||
"mipsel_24kc" => "mipsel_24kc",
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"aarch64_cortex-a72" => "aarch64_cortex-a72",
|
||||
"arm_cortex-a7" => "arm_cortex-a7",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.ipk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct-download URLs for the native Alpine-style `.apk` package format —
|
||||
/// only published for a subset of architectures as of v0.5.0. Where
|
||||
/// available this is strictly better than [`ipk_url`] on an apk-native
|
||||
/// (OpenWrt 25.x+) router: `apk add` installs it directly (dependency
|
||||
/// resolution, postinst, uci-defaults all handled by apk itself), instead of
|
||||
/// the manual `ar`/`tar` extraction dance `install_ipk` has to do to unpack
|
||||
/// an `.ipk` on a router with no `opkg`.
|
||||
fn apk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.apk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
|
||||
@@ -34,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
}
|
||||
|
||||
// Package not in any feed — download the .ipk directly.
|
||||
let arch = router
|
||||
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
|
||||
let arch = router.run_ok(
|
||||
"opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
|
||||
)?;
|
||||
let arch = arch.trim();
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
@@ -88,7 +124,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
". /etc/openwrt_release 2>/dev/null \
|
||||
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
|
||||
&& [ -n \"$a\" ] && echo \"$a\" \
|
||||
|| /usr/bin/apk --print-arch 2>/dev/null \
|
||||
|| apk --print-arch 2>/dev/null \
|
||||
|| uname -m",
|
||||
)?;
|
||||
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
|
||||
@@ -103,6 +139,38 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
anyhow::bail!("Could not determine router architecture");
|
||||
}
|
||||
|
||||
// Prefer a native .apk when the release publishes one for this arch —
|
||||
// `apk add` handles the install itself (deps, postinst, uci-defaults),
|
||||
// skipping the manual ar/tar extraction the .ipk fallback below needs.
|
||||
if let Some(url) = apk_url(arch) {
|
||||
info!(
|
||||
"[{}] Downloading native TollGate .apk for {} from GitHub releases",
|
||||
router.host, arch
|
||||
);
|
||||
let (dl_out, dl_code) = router.run(&format!(
|
||||
"wget --no-check-certificate -O /tmp/tollgate.apk '{}' 2>&1",
|
||||
url
|
||||
))?;
|
||||
if dl_code != 0 {
|
||||
anyhow::bail!("TollGate .apk download failed: {}", dl_out.trim());
|
||||
}
|
||||
let (size_out, _) = router.run("wc -c < /tmp/tollgate.apk 2>/dev/null")?;
|
||||
let size: u64 = size_out.trim().parse().unwrap_or(0);
|
||||
if size < 50_000 {
|
||||
anyhow::bail!(
|
||||
"Downloaded TollGate .apk is only {}B — wget likely captured an error page. \
|
||||
Check router internet access and that the release URL is reachable.",
|
||||
size
|
||||
);
|
||||
}
|
||||
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
||||
router.run_ok("rm -f /tmp/tollgate.apk")?;
|
||||
if add_code != 0 {
|
||||
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No pre-built TollGate package for architecture '{}'. \
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Local test artifacts only — the shipped image seeds data/ and server/data/
|
||||
# from data-template/ at container start (see entrypoint.sh); nothing real
|
||||
# belongs in this build context.
|
||||
server/node_modules/
|
||||
data/
|
||||
server/data/
|
||||
@@ -0,0 +1,43 @@
|
||||
# Dojo Bay, packaged as an Archipelago app.
|
||||
#
|
||||
# Node 24 is required: the backend runs .ts directly via Node's type-stripping,
|
||||
# and its BIP47 libraries need it too (see the upstream project's README).
|
||||
# nginx serves the static directory site and proxies /api/ to the Node
|
||||
# backend in the same container — see nginx.conf for why both live here
|
||||
# instead of relying on a systemd pair the way the standalone deploy did.
|
||||
#
|
||||
# Runs fully rootless: no `user` directive in nginx.conf, so nginx's master
|
||||
# and worker processes just inherit whatever UID started them (dojobay,
|
||||
# below) — no privilege to drop, none ever held.
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:24-alpine
|
||||
RUN apk add --no-cache nginx tini \
|
||||
&& addgroup -S dojobay && adduser -S dojobay -G dojobay
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/server/node_modules /app/server/node_modules
|
||||
COPY server/ /app/server/
|
||||
COPY scripts/ /app/scripts/
|
||||
COPY assets/ /app/assets/
|
||||
COPY content/ /app/content/
|
||||
COPY types.d.ts /app/types.d.ts
|
||||
COPY index.html favicon.svg manifest.json sw.js /app/
|
||||
COPY data-template/ /app/data-template/
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /app/data /app/server/data \
|
||||
&& chown -R dojobay:dojobay /app \
|
||||
&& chown -R dojobay:dojobay /var/lib/nginx /var/log/nginx /run
|
||||
|
||||
USER dojobay:dojobay
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -q -O- http://127.0.0.1:8080/ >/dev/null || exit 1
|
||||
|
||||
# tini reaps the two children (node + nginx) and forwards signals cleanly.
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,393 @@
|
||||
/* Self-hosted variable fonts (latin subset). No external CDN. */
|
||||
@font-face{font-family:'Archivo';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/archivo.woff2') format('woff2')}
|
||||
@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/hanken-grotesk.woff2') format('woff2')}
|
||||
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url('../fonts/jetbrains-mono.woff2') format('woff2')}
|
||||
|
||||
:root{
|
||||
--bg:#0a0a0a; --panel:#141414; --panel2:#1c1c1c; --line:#2a2a2a; --line-soft:#1c1c1c;
|
||||
--text:#f4f4f3; --muted:#a0a0a0; --faint:#6b6b6b;
|
||||
--accent:#b5302a; --accent-2:#d6534a; --accent-bg:rgba(181,48,42,.12); --accent-line:rgba(181,48,42,.34);
|
||||
--btc:#f7931a; --btc-text:#1a1206; --grey-sel:#8a8a8a;
|
||||
--up:#3fb950; --up-bg:rgba(63,185,80,.14); --down:#d6584f; --down-dim:#5a3330;
|
||||
/* Every use of this was written as var(--warn,#e0a020) against a token that
|
||||
was never declared, so the fallback always won. Declared here so the
|
||||
amber is adjustable in one place; --mid is the 90-day middle band, which
|
||||
was a bare hex literal for the same reason. */
|
||||
--warn:#e0a020; --mid:#b9a13a;
|
||||
/* Same class of bug, found by auditing every var() reference against the
|
||||
declarations: .admin-row asked for --card and had been taking #0e0e10 by
|
||||
fallback since it was written. Declared at that value rather than at
|
||||
--panel, so nothing changes appearance; whether the admin rows were meant
|
||||
to sit a shade darker than the cards they resemble is a separate
|
||||
question, and not one to answer by accident a second time. */
|
||||
--card:#0e0e10;
|
||||
--code-bg:#070707; --code-fg:#e6a39b;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{background:var(--bg);color:var(--text)}
|
||||
body{font-family:'Hanken Grotesk',system-ui,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
.mono{font-family:'JetBrains Mono',ui-monospace,monospace}
|
||||
.disp{font-family:'Archivo',sans-serif}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
||||
.wrap{max-width:1120px;margin:0 auto;padding:0 22px}
|
||||
.eyebrow{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint)}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
||||
|
||||
|
||||
header{border-bottom:1px solid var(--line-soft);position:sticky;top:0;background:rgba(11,11,12,.86);backdrop-filter:blur(8px);z-index:20}
|
||||
header .wrap{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 22px}
|
||||
.brand{display:flex;align-items:center;gap:12px}
|
||||
.brand .name{font-weight:800;font-size:18px;letter-spacing:-.01em}
|
||||
.brand .sub{font-size:10.5px;color:var(--faint);margin-top:1px;letter-spacing:.04em}
|
||||
nav{display:flex;gap:6px;align-items:center}
|
||||
nav .lnk{color:var(--muted);font-size:14px;padding:7px 11px;border-radius:7px;transition:color .15s,background .15s}
|
||||
nav .lnk:hover{color:var(--text);background:var(--panel)}
|
||||
.onion-pill{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);border:1px solid var(--accent-line);background:var(--accent-bg);padding:6px 10px;border-radius:7px;margin-left:4px}
|
||||
.onion-pill:hover{background:rgba(247,147,26,.16)}
|
||||
.burger{display:none;background:none;border:0;color:var(--text);padding:6px;cursor:pointer;border-radius:7px}
|
||||
/* The Auth47 challenge, shown under its QR. Wraps anywhere because it is one
|
||||
unbroken token, and carries its own copy button for the same-device case. */
|
||||
/* Column, always, at every width. Side by side the button lands wherever the
|
||||
URI happens to stop wrapping, so it sits mid-line on one screen and below
|
||||
on another, and on the narrow case it crowds the text it belongs to. The
|
||||
URI is a single unbroken token that has to wrap anyway, so there is no
|
||||
width at which a row reads better. */
|
||||
.a47-uri{display:flex;flex-direction:column;align-items:center;gap:10px;
|
||||
margin-top:10px;text-align:left}
|
||||
.a47-uri code{font-size:10.5px;color:var(--faint);word-break:break-all;line-height:1.5;
|
||||
max-width:44ch;width:100%}
|
||||
.a47-uri .copybtn{align-self:center}
|
||||
.upd-line{margin:6px 0}
|
||||
/* Self-update has never completed a run on real hardware. The badge is not
|
||||
decoration: a maintainer clicking Update from GitHub is the first person
|
||||
who will find out whether it works, and should know that before clicking. */
|
||||
.upd-exp{display:inline-block;font-size:10px;letter-spacing:.08em;text-transform:uppercase;
|
||||
font-family:'JetBrains Mono',monospace;color:var(--warn,#e0a020);
|
||||
border:1px solid rgba(224,160,32,.45);background:rgba(224,160,32,.10);
|
||||
border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:1px}
|
||||
/* Full width. It was capped at 62ch, which is right for prose a reader is
|
||||
settling into and wrong for a warning beside the control it warns about:
|
||||
it left the paragraph as a narrow column against a wide panel, and the
|
||||
ragged right edge read as a layout fault rather than as deliberate
|
||||
measure. */
|
||||
.upd-exp-note{font-size:11.5px;color:var(--faint);line-height:1.55;margin:8px 0 0;width:100%}
|
||||
.upd-none{font-size:11.5px;color:var(--faint);margin:6px 0 0}
|
||||
/* An update that did not finish. Warning-coloured rather than faint, because
|
||||
the failure it describes is invisible everywhere else: the code is on disk,
|
||||
the footer already shows the new build, and only the process serving the
|
||||
page is stale. */
|
||||
.upd-warn{font-size:12px;line-height:1.55;margin:8px 0 0;padding:9px 11px;border-radius:7px;
|
||||
color:#e9d6d2;background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.45)}
|
||||
.upd-warn b{color:var(--down)}
|
||||
.upd-controls{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||||
.upd-bar{height:8px;border-radius:6px;background:var(--panel2);overflow:hidden;margin:8px 0}
|
||||
.upd-bar-fill{height:100%;transition:width .4s ease;border-radius:6px}
|
||||
.upd-log{font-size:11px;color:var(--faint);background:var(--panel2);border-radius:8px;padding:8px 10px;margin:6px 0;white-space:pre-wrap;line-height:1.5;max-height:120px;overflow:auto}
|
||||
/* The import plan. Refused rows are coloured rather than hidden: a directory
|
||||
publishing listings this instance will not accept is the most informative
|
||||
thing on the table, and collapsing it to a count would bury it. */
|
||||
table.imp{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
|
||||
table.imp th{text-align:left;font-weight:600;color:var(--faint);font-size:11px;
|
||||
padding:4px 8px 4px 0;border-bottom:1px solid var(--line-soft)}
|
||||
table.imp td{padding:5px 8px 5px 0;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr.imp-merge td{color:var(--muted)}
|
||||
tr.imp-refuse td{color:var(--down)}
|
||||
.op-avatar{width:20px;height:20px;border-radius:50%;object-fit:cover;
|
||||
border:1px solid var(--line-soft);display:inline-block;vertical-align:middle}
|
||||
/* PayNym avatar centred on the pairing QR (QR is generated at EC level H,
|
||||
so the ~5% of symbol area the avatar covers is well within recovery) */
|
||||
.tile{position:relative}
|
||||
.qr-avatar{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:21%;height:21%;object-fit:cover;border-radius:8px;
|
||||
border:3px solid #fff;background:#fff}
|
||||
/* payment code chip on cards: truncated, click copies the full code */
|
||||
/* The payment code owns its own line and spans the card, so it reads as the
|
||||
identity of the listing rather than one chip among several. The verified
|
||||
domain and its verify button sit on the line beneath. */
|
||||
.pcode{display:block;width:100%;text-align:left;margin:2px 0 8px;padding:5px 11px;font-size:11.5px;
|
||||
letter-spacing:.02em;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
background:var(--panel2);border:1px solid var(--line-soft);border-radius:8px;cursor:pointer}
|
||||
.pcode:hover{color:var(--text);border-color:var(--accent)}
|
||||
.pcode.done{color:var(--up)}
|
||||
/* inline display-field editor (Manage rows and admin rows) */
|
||||
.medit{margin-top:10px;padding-top:10px;border-top:1px solid var(--line-soft);display:flex;flex-direction:column;gap:8px}
|
||||
.medit label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)}
|
||||
.medit input{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;color:var(--text);
|
||||
padding:7px 9px;font-size:13px;font-family:inherit}
|
||||
.medit input:focus{outline:none;border-color:var(--accent)}
|
||||
.medit-actions{display:flex;gap:8px;align-items:center}
|
||||
.copybtn[disabled],.abtn[disabled]{opacity:.4;cursor:default}
|
||||
.burger:hover{background:var(--panel)}
|
||||
|
||||
.controls{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:30px 22px 18px}
|
||||
.seg{display:inline-flex;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:3px}
|
||||
.seg button{font-family:'JetBrains Mono',monospace;font-size:12px;padding:8px 18px;border-radius:7px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);transition:background .15s,color .15s}
|
||||
.seg button.on{color:#0a0a0a;font-weight:700}
|
||||
.seg button[data-net="mainnet"].on{background:var(--btc);color:var(--btc-text)}
|
||||
.seg button[data-net="testnet"].on{background:var(--grey-sel);color:#0a0a0a}
|
||||
.fresh{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
||||
.fresh .dot{width:7px;height:7px;border-radius:99px;background:var(--up);display:inline-block;box-shadow:0 0 0 3px var(--up-bg)}
|
||||
.fresh b{color:var(--text);font-weight:700}
|
||||
.fresh .sep{color:var(--faint)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.card{background:var(--panel);border:1px solid var(--line-soft);border-radius:12px;padding:18px;transition:border-color .18s,transform .18s}
|
||||
.card:hover{border-color:var(--line);transform:translateY(-2px)}
|
||||
.card.inactive{opacity:.74}
|
||||
.ctop{display:flex;align-items:center;gap:10px}
|
||||
.ctop .sd{width:9px;height:9px;border-radius:99px;flex-shrink:0}
|
||||
.sd.active{background:var(--up);box-shadow:0 0 0 3px var(--up-bg)}
|
||||
.sd.inactive{background:var(--down);box-shadow:0 0 0 3px rgba(214,88,79,.14)}
|
||||
.cname{font-family:'Archivo',sans-serif;font-weight:700;font-size:16px;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
a.cname{transition:color .15s}
|
||||
a.cname:hover{color:var(--accent)}
|
||||
a.cname .ext{font-size:11px;color:var(--faint);vertical-align:middle}
|
||||
a.cname:hover .ext{color:var(--accent)}
|
||||
.cbadge{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:5px;font-weight:700;flex-shrink:0}
|
||||
/* Stale data: the updater has not refreshed dojos.json for several intervals,
|
||||
so we stop asserting status. Badges go neutral rather than green or red,
|
||||
because "unknown" is the honest answer, not "down". */
|
||||
/* The empty directory. Deliberately quiet: a bordered panel rather than a
|
||||
warning colour, because an instance with nothing published yet is usually
|
||||
new rather than broken. */
|
||||
.empty{border:1px dashed var(--line);border-radius:10px;padding:26px 22px;text-align:center;
|
||||
color:var(--muted);font-size:14px;line-height:1.65;margin:0 0 18px}
|
||||
.empty b{color:var(--text)}
|
||||
.empty-cta{margin-top:8px;font-size:13px;color:var(--faint)}
|
||||
/* The Tor port picker. Two presets, because there are two answers in practice
|
||||
and a free-text field would invite typos into the one value that has to be
|
||||
right for any of the commands below it to work. */
|
||||
.portpick{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0 14px}
|
||||
.portpick .k{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.06em;
|
||||
text-transform:uppercase;color:var(--faint)}
|
||||
.pbtn{font:inherit;font-family:'JetBrains Mono',monospace;font-size:13px;padding:6px 11px;
|
||||
border-radius:7px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer}
|
||||
.pbtn .w{display:block;font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;color:var(--faint);
|
||||
letter-spacing:0;text-transform:none;margin-top:1px}
|
||||
.pbtn:hover{border-color:var(--line-soft);color:var(--text)}
|
||||
.pbtn.on{border-color:var(--accent);color:var(--accent-2);background:rgba(181,48,42,.10)}
|
||||
.pbtn.on .w{color:var(--accent-2)}
|
||||
.stale-banner{margin:0 0 18px;padding:12px 14px;border-radius:8px;font-size:13.5px;line-height:1.6;
|
||||
color:var(--text);background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.35)}
|
||||
.stale-banner b{color:var(--down)}
|
||||
.grid.stale .sd.active,.grid.stale .sd.inactive{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)}
|
||||
.grid.stale .cbadge.active,.grid.stale .cbadge.inactive{color:var(--faint);background:var(--panel2)}
|
||||
.grid.stale .card{opacity:.92}
|
||||
.fresh.stale .dot{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)}
|
||||
.cbadge.active{color:var(--up);background:var(--up-bg)}
|
||||
.cbadge.inactive{color:var(--down);background:rgba(214,88,79,.12)}
|
||||
.csub{display:flex;align-items:center;gap:8px;margin:9px 0 2px;font-size:13px;flex-wrap:wrap}
|
||||
.csub .pn{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--accent)}
|
||||
.csub .pn:hover{text-decoration:underline}
|
||||
.csub .jur{color:var(--muted);display:inline-flex;align-items:center;gap:5px}
|
||||
.csub .flag{font-size:14px;line-height:1}
|
||||
.csub .nopn{color:var(--faint);font-style:italic;font-size:12.5px}
|
||||
|
||||
.rel{margin:15px 0 4px}
|
||||
.rel-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
|
||||
.rel-head .pct{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700}
|
||||
.rel-head .pct .n{color:var(--faint);font-weight:400}
|
||||
.rel-bars{display:flex;gap:2px;align-items:stretch;height:26px}
|
||||
.rel-bars .b{flex:1;min-width:2px;border-radius:1px;background:var(--down-dim)}
|
||||
.rel-bars .b.up{background:var(--up)}
|
||||
.rel-bars .b.down{background:var(--down)}
|
||||
.rel-axis{display:flex;justify-content:space-between;margin-top:5px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--faint)}
|
||||
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:11px 16px;margin:14px 0 4px}
|
||||
.meta .full{grid-column:1/-1}
|
||||
.meta .v{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--text);margin-top:2px;word-break:break-word}
|
||||
|
||||
.reveal{width:100%;padding:11px;border-radius:8px;background:var(--accent-bg);border:1px solid var(--accent-line);color:var(--accent-2);font-weight:600;font-size:13.5px;margin-top:14px;transition:background .15s}
|
||||
.reveal:hover{background:rgba(247,147,26,.16)}
|
||||
.reveal.open{color:var(--muted);border-color:var(--line)}
|
||||
/* Secondary action under the primary one: same shape, quieter, so pairing
|
||||
stays the obvious thing to click. */
|
||||
.reveal.secondary{background:var(--panel2);border-color:var(--line);color:var(--muted);
|
||||
font-weight:500;font-size:12.5px;padding:9px;margin-top:8px}
|
||||
.reveal.secondary:hover{color:var(--text);border-color:var(--line-soft);background:var(--panel2)}
|
||||
|
||||
.pair{margin-top:14px;animation:rise .25s ease}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
|
||||
.qr{display:flex;flex-direction:column;align-items:center;gap:7px;margin-bottom:14px}
|
||||
.qr .tile{background:#fff;border:1px solid var(--accent-line);border-radius:10px;padding:12px;line-height:0}
|
||||
.qr .tile svg{display:block;border-radius:2px}
|
||||
.qr .cap{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
|
||||
.box{margin-bottom:12px}
|
||||
.box .lbl,.modal-body>.lbl{display:flex;justify-content:space-between;align-items:center;margin-bottom:7px}
|
||||
.modal-body>.lbl{margin:18px 0 8px;gap:12px}
|
||||
.box .lbl .t,.modal-body>.lbl .t{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
|
||||
.box pre{font-family:'JetBrains Mono',monospace;font-size:10.5px;line-height:1.55;background:var(--code-bg);color:var(--code-fg);border:1px solid var(--line);border-radius:8px;padding:13px;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow:auto}
|
||||
.box.signed pre{color:#cdd6e4;font-size:10px}
|
||||
.copybtn{font-family:'JetBrains Mono',monospace;font-size:11px;padding:5px 11px;border:1px solid var(--accent-line);border-radius:6px;color:var(--accent);background:var(--accent-bg);transition:background .15s}
|
||||
.copybtn:hover{background:rgba(247,147,26,.18)}
|
||||
.copybtn.done{color:var(--up);border-color:rgba(63,185,80,.4);background:var(--up-bg)}
|
||||
.eps{margin-top:4px;display:flex;flex-direction:column;gap:8px}
|
||||
.card-eps{margin-top:14px;display:flex;flex-direction:column;gap:7px}
|
||||
.ep{display:flex;align-items:center;gap:9px}
|
||||
.ep .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);min-width:62px}
|
||||
.ep .u{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muted);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
/* Verified operator domain: a quiet badge, not a trust mark. It sits beside the
|
||||
payment-code chip and attests to control of the domain only. */
|
||||
.vdomain{display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace;
|
||||
font-size:11px;padding:4px 8px;border-radius:6px;text-decoration:none;
|
||||
color:var(--up);border:1px solid rgba(63,185,80,.35);background:var(--up-bg);white-space:nowrap;
|
||||
max-width:190px;overflow:hidden;text-overflow:ellipsis}
|
||||
.vdomain:hover{border-color:rgba(63,185,80,.6)}
|
||||
/* "For the machines among us": an unobtrusive way to interrogate the badge. */
|
||||
/* Domain and its verify button, on the line below the payment code. The
|
||||
domain takes the free space so a long one truncates instead of pushing the
|
||||
button off the card. */
|
||||
.vrow{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:nowrap}
|
||||
.vrow .vdomain{flex:1 1 auto;min-width:0;max-width:none}
|
||||
.vrow .vproof{flex:0 0 auto}
|
||||
.vproof{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.06em;
|
||||
padding:4px 7px;border-radius:6px;color:var(--faint);border:1px solid var(--line);
|
||||
background:var(--panel2);cursor:pointer}
|
||||
.vproof:hover{color:var(--muted);border-color:var(--line-soft)}
|
||||
/* A warning that must not be skimmed past: the XPUB advice is the one place
|
||||
where following the page carelessly could cost a reader their privacy. */
|
||||
.warnbox{border:1px solid rgba(214,88,79,.45);background:rgba(214,88,79,.10);
|
||||
border-radius:8px;padding:11px 13px;margin:0 0 12px;font-size:13px;line-height:1.6}
|
||||
.warnbox b{color:var(--down)}
|
||||
.proofblk{margin:0 0 12px}
|
||||
.proofblk .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--faint);margin-bottom:4px}
|
||||
.proofblk pre{margin:0 0 6px;padding:9px 10px;background:var(--panel2);border:1px solid var(--line);
|
||||
border-radius:6px;font-size:11.5px;white-space:pre-wrap;word-break:break-all;color:var(--muted)}
|
||||
/* Verified-domain setup box in Manage. */
|
||||
.dbox{border:1px solid var(--line);border-radius:8px;padding:12px 13px;background:var(--panel2);margin-bottom:12px}
|
||||
.dbox p{margin:0 0 8px}
|
||||
.dbox .dnote{font-size:12.5px;color:var(--muted)}
|
||||
.dbox .dmsg{font-size:12.5px;color:var(--accent);margin-top:8px;
|
||||
overflow-wrap:anywhere;word-break:break-word;line-height:1.5}
|
||||
/* Anything that can contain a payment code, an onion or a TXT value. */
|
||||
.dbox .dnote,.dbox .dwrap{overflow-wrap:anywhere;word-break:break-word}
|
||||
.dbox{overflow:hidden}
|
||||
.dbox .ok-tick{color:var(--up)}
|
||||
.dbox .ep{margin-bottom:6px}
|
||||
.dsign{font-size:11.5px;background:var(--panel);border:1px solid var(--line);border-radius:6px;
|
||||
padding:9px 10px;white-space:pre-wrap;word-break:break-all;color:var(--muted);margin:0 0 8px}
|
||||
.dbox textarea{width:100%;font-family:'JetBrains Mono',monospace;font-size:11.5px}
|
||||
.ep .copybtn{flex-shrink:0}
|
||||
/* An endpoint the node does not publish. The field keeps the same box as the
|
||||
other endpoints so the rows line up; only the text colour marks it as not
|
||||
being a value. The copy button stays in place, inert, so the row does not
|
||||
change width or lose its right-hand column. */
|
||||
.ep .u.na{color:var(--faint)}
|
||||
.copybtn[disabled]{opacity:.4;cursor:default;color:var(--faint);border-color:var(--line);background:var(--panel2)}
|
||||
.copybtn[disabled]:hover{background:var(--panel2)}
|
||||
|
||||
.note{margin:30px 0 8px;font-size:13.5px;color:var(--muted);line-height:1.65}
|
||||
.note a{color:var(--accent);font-weight:600}
|
||||
.note a:hover{text-decoration:underline}
|
||||
|
||||
footer{border-top:1px solid var(--line-soft);padding:24px 0;margin-top:18px}
|
||||
footer .wrap{display:flex;justify-content:center}
|
||||
footer .gh{color:var(--faint);display:inline-flex;align-items:center;transition:color .15s}
|
||||
footer .gh:hover{color:var(--text)}
|
||||
footer .gh svg{display:block}
|
||||
|
||||
.ov{position:fixed;inset:0;background:rgba(4,4,5,.72);backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;padding:6vh 18px;z-index:50;overflow:hidden}
|
||||
.ov.show{display:flex}
|
||||
.modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;max-width:700px;width:100%;padding:0;box-shadow:0 24px 60px rgba(0,0,0,.5);display:flex;flex-direction:column;max-height:88vh;min-height:0;overflow:hidden}
|
||||
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--line-soft);background:var(--panel);border-radius:14px 14px 0 0;flex:0 0 auto}
|
||||
.modal-head h2{font-family:'Archivo',sans-serif;font-size:19px;font-weight:700}
|
||||
.modal-head .x{font-size:22px;color:var(--muted);line-height:1;padding:2px 8px;border-radius:6px}
|
||||
.modal-head .x:hover{background:var(--panel2);color:var(--text)}
|
||||
.modal-body{padding:22px 24px 26px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
|
||||
.modal-body p{font-size:14px;color:#d7d7d4;line-height:1.7;margin-bottom:13px}
|
||||
.modal-body h2{font-family:'Archivo',sans-serif;font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);margin:26px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--line-soft)}
|
||||
.modal-body h2:first-child{margin-top:0}
|
||||
.modal-body h3{font-family:'Archivo',sans-serif;font-weight:700;color:var(--text);font-size:14.5px;margin:18px 0 4px}
|
||||
.modal-body strong{color:var(--text)}
|
||||
.modal-body a{color:var(--accent);font-weight:600;word-break:break-word}
|
||||
.modal-body a:hover{text-decoration:underline}
|
||||
.modal-body ul{margin:0 0 13px 2px;padding:0;list-style:none}
|
||||
.modal-body li{font-size:14px;color:#d7d7d4;line-height:1.6;margin-bottom:6px;padding-left:2px}
|
||||
.modal-body code{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent);background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:2px 6px}
|
||||
.modal-body blockquote{background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:14px 16px;margin:0 0 16px}
|
||||
.modal-body blockquote p{font-size:13.5px;margin-bottom:9px}
|
||||
.modal-body blockquote p:last-child{margin-bottom:0}
|
||||
.modal-body blockquote code{display:inline-block;color:var(--accent);font-size:14px}
|
||||
.modal-body .loading{color:var(--faint);font-family:'JetBrains Mono',monospace;font-size:13px}
|
||||
@media (max-width:560px){
|
||||
.meta{grid-template-columns:1fr}
|
||||
/* The network toggle and the freshness line sit at opposite ends of one row
|
||||
on a wide screen, which is what space-between is for. On a narrow one they
|
||||
wrap onto separate lines, and space-between then puts a lone item at the
|
||||
start of its line, so both ended up hard against the left edge under a
|
||||
centred header. Centre them instead: the toggle is the page's primary
|
||||
control and reads as a control rather than a stray pair of words when it
|
||||
is centred under the title.
|
||||
.fresh is itself a flex container whose own content wraps, so it needs
|
||||
centring too, or its second line ("re-checks every 10 min") hangs left
|
||||
under a centred first line, which looks like a mistake rather than a
|
||||
wrap. text-align covers any inline content that is not a flex item. */
|
||||
.controls{justify-content:center;gap:12px;padding:22px 18px 14px}
|
||||
.fresh{justify-content:center;text-align:center}
|
||||
header .wrap{padding:14px 18px;position:relative;justify-content:flex-start}
|
||||
.burger{display:block;z-index:2}
|
||||
/* Centre the title while the hamburger is in use. Only then: taking .brand
|
||||
out of flow leaves the burger as the header's only in-flow child, and on
|
||||
a page that has no burger the header collapses to its padding, so the
|
||||
brand overlaps whatever is beneath it. That is what the operator console
|
||||
looked like on a phone, its title clipped over the Moderation heading,
|
||||
and its one nav link was unreachable as well because the nav becomes a
|
||||
dropdown with nothing to open it. */
|
||||
header:not(.no-menu) .brand{position:absolute;left:50%;transform:translateX(-50%)}
|
||||
header.no-menu .wrap{justify-content:space-between;gap:10px}
|
||||
header.no-menu nav{display:flex;position:static;flex-direction:row;background:none;
|
||||
backdrop-filter:none;border:0;padding:0}
|
||||
header.no-menu nav .lnk{padding:8px 10px;font-size:14px;white-space:nowrap}
|
||||
header.no-menu .brand .name{font-size:16px}
|
||||
/* the nav becomes a full-width dropdown under the header */
|
||||
nav{display:none;position:absolute;top:100%;left:0;right:0;flex-direction:column;align-items:stretch;gap:2px;
|
||||
background:rgba(11,11,12,.97);backdrop-filter:blur(8px);border-bottom:1px solid var(--line-soft);padding:8px 14px 12px}
|
||||
nav.open{display:flex}
|
||||
nav .lnk{display:block;text-align:center;padding:12px;font-size:15px}
|
||||
nav .onion-pill{text-align:center;margin:6px 0 0}
|
||||
/* Less chrome around the dialog on a small screen, so the body gets the
|
||||
height. The scrolling still happens inside .modal-body. */
|
||||
.ov{padding:3vh 10px}
|
||||
.modal{max-height:94vh}
|
||||
.modal-head{padding:16px 18px}
|
||||
.modal-body{padding:18px 18px 22px}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.card:hover{transform:none}.pair{animation:none}}
|
||||
|
||||
/* Manage my Dojo form */
|
||||
.mform{display:flex;flex-direction:column;gap:12px}
|
||||
.mform label{display:flex;flex-direction:column;gap:5px;font-size:12.5px;color:var(--muted)}
|
||||
.mform input,.mform select,.mform textarea{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:9px 10px;font-family:'JetBrains Mono',monospace;font-size:12.5px;width:100%}
|
||||
.mform textarea{resize:vertical;line-height:1.5}
|
||||
.mform input:focus,.mform select:focus,.mform textarea:focus{outline:none;border-color:var(--accent-line)}
|
||||
|
||||
/* 90-day daily history (on the card, below the 24h strip) */
|
||||
.hist90{margin-top:12px}
|
||||
.d90strip{display:flex;gap:1px;align-items:flex-end;height:22px;margin:8px 0 6px}
|
||||
.d90{flex:1 1 0;min-width:1px;height:100%;border-radius:1px;background:var(--line)}
|
||||
.d90.up{background:var(--up)} .d90.mid{background:var(--mid)} .d90.down{background:var(--down)} .d90.na{background:var(--line)}
|
||||
.d90foot{display:flex;justify-content:space-between;font-size:11px;font-family:'JetBrains Mono',monospace}
|
||||
.spark{display:block;margin-top:6px;opacity:.9}
|
||||
|
||||
footer .ver{margin-left:12px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--faint)}
|
||||
footer .ver a{color:var(--faint)} footer .ver a:hover{color:var(--text)}
|
||||
|
||||
/* footer verify link */
|
||||
footer .wrap{display:flex;align-items:center;gap:10px}
|
||||
.foot-spacer{flex:1}
|
||||
.verify-pre{background:var(--panel,#111);border:1px solid var(--line);border-radius:8px;padding:12px;font-family:'JetBrains Mono',monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text);margin-top:6px}
|
||||
|
||||
/* admin console */
|
||||
.admin-row{border:1px solid var(--line);border-radius:10px;padding:14px;margin:10px 0;background:var(--card,#0e0e10)}
|
||||
.admin-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}
|
||||
.abadge{font-size:10px;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--muted)}
|
||||
.abadge.pending{color:#b9a13a;border-color:#b9a13a}
|
||||
.abadge.approved{color:var(--up);border-color:var(--up)}
|
||||
.abadge.rejected{color:var(--down);border-color:var(--down)}
|
||||
.admin-actions{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
|
||||
.abtn{font:inherit;font-size:13px;padding:7px 14px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}
|
||||
.abtn.ok{border-color:var(--up);color:var(--up)}
|
||||
.abtn.danger{border-color:var(--down);color:var(--down)}
|
||||
.abtn:disabled{opacity:.5}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
// Minimal, dependency-free Markdown renderer.
|
||||
// Supports the subset used by the content/*.md files: headings (#..######),
|
||||
// paragraphs, unordered lists (- / *), blockquotes (>), and the inline forms
|
||||
// **bold**, `code`, and [text](url). HTML in the source is escaped, so content
|
||||
// authors can write plain Markdown without worrying about markup.
|
||||
//
|
||||
// ON TRUST. Everything this renders today is written by whoever maintains the
|
||||
// instance and shipped in the repository: content/about.md and content/faq.md,
|
||||
// and nothing else calls markdown.render. Under that assumption the escaping
|
||||
// below is a convenience, not a boundary, because an author who wanted a script
|
||||
// tag on the page could simply put one in index.html.
|
||||
//
|
||||
// It is nonetheless written as though the input were hostile, because the gap
|
||||
// between "only maintainers write this" and "anyone can" is one call site. If a
|
||||
// future change renders ANY of the following, this file becomes a real security
|
||||
// boundary and should be read again with that in mind:
|
||||
// - a submission field (node name, jurisdiction, hardware, the operator note)
|
||||
// - anything fetched from another instance, including during a bootstrap
|
||||
// import or a federated update
|
||||
// - a file an operator can drop into content/ without a commit
|
||||
// Two things in particular were fixed ahead of that day: the quote character
|
||||
// was not escaped, so a link URL could close the href attribute and open a new
|
||||
// one (browsers accept `href="x"onfocus=…` without whitespace); and any scheme
|
||||
// at all was accepted, so javascript: and data: URLs became live links.
|
||||
(function (global) {
|
||||
// Quotes included. Without them, escaping is enough for TEXT but not for an
|
||||
// attribute value, and the link rule below interpolates into href="…".
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// An allowlist, not a denylist of the schemes that happen to be dangerous
|
||||
// today. http and https cover every link in the content and every link a
|
||||
// reader of an onion site should be following; anything else, including
|
||||
// javascript:, data:, vbscript: and file:, renders as plain text so the
|
||||
// author can see their link did not work rather than shipping a live one.
|
||||
//
|
||||
// Applied to the RAW url, before entity-escaping: "javascript:x" is not
|
||||
// a scheme this accepts, and the check must not be fooled by a spelling that
|
||||
// only becomes a scheme after the browser decodes it. Leading control
|
||||
// characters and whitespace are stripped first for the same reason, since
|
||||
// browsers ignore them when resolving a URL.
|
||||
function safeUrl(u) {
|
||||
const cleaned = u.replace(/[\u0000-\u0020]/g, "");
|
||||
// A scheme is everything before the first colon, if that comes before the
|
||||
// first slash, question mark or hash. No colon in that position means a
|
||||
// relative URL, which cannot execute anything.
|
||||
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
||||
if (!m) return !/^\/\//.test(cleaned) ? cleaned : null; // protocol-relative is not relative
|
||||
const scheme = m[1].toLowerCase();
|
||||
return scheme === "http" || scheme === "https" ? cleaned : null;
|
||||
}
|
||||
|
||||
function inline(s) {
|
||||
s = escapeHtml(s);
|
||||
s = s.replace(/`([^`]+)`/g, (_, c) => "<code>" + c + "</code>");
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, t, u) => {
|
||||
// u arrives already entity-escaped, and that is fine to judge directly:
|
||||
// none of & < > " ' is a legal scheme character, so escaping cannot turn
|
||||
// a dangerous scheme into an acceptable one or the reverse. Decoding
|
||||
// first, which an earlier version did to "see what the browser sees",
|
||||
// bought nothing and introduced a double-unescape (CodeQL js/double-
|
||||
// escaping) where &#39; unwound one layer too many.
|
||||
if (!safeUrl(u)) return whole; // leave the markdown visible, unlinked
|
||||
return '<a href="' + u + '" target="_blank" rel="noopener">' + t + "</a>";
|
||||
});
|
||||
return s;
|
||||
}
|
||||
function render(md) {
|
||||
const lines = String(md).replace(/\r\n/g, "\n").split("\n");
|
||||
let html = "", i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^\s*$/.test(line)) { i++; continue; }
|
||||
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) { const l = h[1].length; html += `<h${l}>${inline(h[2].trim())}</h${l}>`; i++; continue; }
|
||||
|
||||
if (/^\s*>/.test(line)) { // blockquote (recurses)
|
||||
const block = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i])) { block.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
||||
html += "<blockquote>" + render(block.join("\n")) + "</blockquote>";
|
||||
continue;
|
||||
}
|
||||
if (/^\s*[-*]\s+/.test(line)) { // unordered list
|
||||
html += "<ul>";
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
html += "<li>" + inline(lines[i].replace(/^\s*[-*]\s+/, "")) + "</li>"; i++;
|
||||
}
|
||||
html += "</ul>";
|
||||
continue;
|
||||
}
|
||||
const para = []; // paragraph
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i]) &&
|
||||
!/^(#{1,6})\s/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i])) {
|
||||
para.push(lines[i].trim()); i++;
|
||||
}
|
||||
html += "<p>" + inline(para.join(" ")) + "</p>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
const api = { render };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.markdown = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
The Dojo Bay exists to give access to people who don't have a Dojo of their own. We encourage everyone to run their own node rather than rely on third parties, and we collect nothing about the people who connect through this directory.
|
||||
|
||||
This site is run by a Dojo operator, and one or more of the nodes listed here are ours. We think that is the right arrangement: whoever maintains a directory of public Dojos should be exposed to the same costs and the same risks as everyone in it. It also means we are not a neutral party, which is precisely why nothing here asks you to take our word for anything.
|
||||
|
||||
**Every listing carries a pairing payload signed by its operator.** That signature is made with the key behind their BIP47 payment code, over the exact onion address, API key and explorer you are about to use, and you can check it with your own wallet or an independent verifier without trusting this site at all. If we were compromised, or simply dishonest, we could not substitute our own onion address into someone else's listing without the signature failing. Because we are a federation of individuals in different jurisdictions we still cannot vouch for how any operator behaves once you connect, but you no longer have to assume the details we publish are the ones they gave us.
|
||||
|
||||
We cannot control when a node goes down, as only its operator can restart it. We make an effort to keep the directory showing only running dojos and re-check every node on a 10-minute cycle, but please conduct your own due diligence.
|
||||
|
||||
We are not affiliated with [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/), [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or Ronin Dojo, though we appreciate their efforts and contributions to the community.
|
||||
|
||||
> **Get listed**
|
||||
>
|
||||
> If you would like your Dojo listed, there is no email and nothing to wait for: open **Manage my Dojo** in the header and sign in with your PayNym over Auth47. Signing the challenge in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) proves you control the payment code without revealing any key, and you can then submit, edit or remove your listing yourself. Every submission must pass a live Tor connection check, a signature check over your pairing payload, and a maintainer review before it is published.
|
||||
>
|
||||
> `Manage my Dojo → Auth47 → sign → submit`
|
||||
@@ -0,0 +1,59 @@
|
||||
## For Dojo seekers
|
||||
|
||||
> **Don't delete your wallet without your passphrase**
|
||||
>
|
||||
> Your [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) passphrase is shown only once, when the wallet is created, and is separate from the PIN you use to open the app; the two are not linked. To switch the Dojo your wallet connects to you must delete and re-create the wallet, so confirm you have the correct passphrase first. The passphrase cannot be recovered, and you need both the 12-word seed phrase and the passphrase to restore a wallet. To check a passphrase, go to **Settings → Wallet → Check BIP39 Passphrase**.
|
||||
>
|
||||
> 🔴 No passphrase: do not delete the wallet. Send the funds to a wallet you control instead.
|
||||
>
|
||||
> 🟢 Passphrase and 12 words: you can safely delete the wallet to change device or connect to another Dojo.
|
||||
>
|
||||
> If you have the passphrase but not the 12 words, you can still open the wallet by decrypting the backup file with the passphrase. If you lose the Dojo connection and don't have the passphrase, export the XPUB to Sparrow for a watch-only wallet and sign offline from [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
### Who is responsible for the listed nodes?
|
||||
|
||||
Not The Dojo Bay: this site is a **directory only**. We do not operate the nodes listed here, we cannot guarantee their uptime, honesty or safety, and we accept no responsibility for them or for any loss of funds or privacy. Status and reliability figures come from automated checks and can be wrong or out of date. Treat every listing as untrusted: verify the pairing details, prefer self-hosting, and connect at your own risk.
|
||||
|
||||
### Are there privacy concerns for Dojo seekers?
|
||||
|
||||
Yes. When you pair with a Dojo you share your extended public key (XPUB), and the operator can use it to view your past, present and future transactions. Only connect to a Dojo you consider reputable and trustworthy, and prefer your own node whenever possible.
|
||||
|
||||
### How do I verify a listing?
|
||||
|
||||
Every listing here is signed, so there is always something to check. Start with the PayNym: confirm it belongs to someone whose reputation you can check, whether stated in a social-media bio, on their own site, or mentioned publicly, and look it up in the [PayNym.rs](http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion) directory to see its code. Then take the signed message from the listing to the [BIP47 Message Verifier](http://ab64uow264ohynkalvlyhdrduwwl75n4urvc2vrbo3xjd4jycygiirqd.onion/lab) and fill in the fields; a correct message returns "Message verified successfully". If verification fails there, use **Tools → Verify message** inside [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
What this proves is narrow and worth being precise about. It proves that whoever holds the key behind that payment code published these exact pairing details, so the onion address and API key you are about to use are the ones their operator put their name to and not something substituted afterwards. It does not prove they are honest, that the node is well run, or that the payment code belongs to the person you think it does. That last part is your job, and it is why the PayNym step comes first.
|
||||
|
||||
### Why doesn't the site verify the signatures for me?
|
||||
|
||||
Because a page that checks its own claims is asking to be trusted twice. If this instance were compromised it could show a green tick over a forged listing just as easily as a real one, so verification done here would be worth nothing at the exact moment you needed it. Doing it in your own wallet or in an independent verifier is the only version of the check that survives us being wrong or dishonest, so we make that as easy as we can and deliberately stop short of doing it for you.
|
||||
|
||||
### Where do I learn to run my own Dojo?
|
||||
|
||||
A Dojo can be installed several ways: [RoninDojo](https://ronindojo.io), a vanilla Dojo (instructions at [dojo-osp.org](https://dojo-osp.org)), or through the [Umbrel](https://apps.umbrel.com/app/samourai-server), [Nodl](https://nodl.eu) and [Start9](https://marketplace.start9.com) marketplaces. It runs on almost any Bitcoin node implementation, giving you full control of your [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) / [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) backend. Treat any public Dojo as strictly temporary or for testing: once your own node is running, migrate your funds to fresh addresses managed by your instance to avoid reusing previously exposed public keys.
|
||||
|
||||
## For Dojo runners
|
||||
|
||||
### Are there privacy concerns for Dojo runners?
|
||||
|
||||
Not security concerns so much as exposure ones. By sharing a pairing payload you reveal your Dojo's onion address, which a malicious party could try to DDoS. You also risk a large number of wallets pairing to your Dojo, so size your hardware accordingly. Until API-key management is fully in place you cannot un-share your pairing details once published.
|
||||
|
||||
### What do I have to sign, and when?
|
||||
|
||||
Your pairing payload, at submission, and again whenever you change it. The signature covers the exact JSON you publish, so a new onion address or a rotated API key needs a new signature over the new details: the old one attests to what you are replacing and will be refused. Sign it with the same PayNym you sign in with, under **PayNym → Sign message** in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/), and paste the whole block including its headers.
|
||||
|
||||
### Is there a minimum Dojo version?
|
||||
|
||||
Yes, 1.27.0, judged on the version your node reports when we probe it rather than the one written into your pairing payload. If your node reports older than that, upgrade it before submitting.
|
||||
|
||||
### Can I change the onion address if I'm being DDoSed?
|
||||
|
||||
Yes, but you will have to re-pair every connected wallet, and update the listing here with a signed payload covering the new address (see above). Until you do, the directory keeps publishing the old one and your listing will show as down.
|
||||
|
||||
### Can I see how many wallets are connected to my Dojo?
|
||||
|
||||
No, and that will not be possible.
|
||||
|
||||
### Can I cap the number if my hardware is limited?
|
||||
|
||||
It isn't really about connections but about tracking a very large number of addresses, and that limit is high even on lower-grade devices.
|
||||
@@ -0,0 +1 @@
|
||||
{ "generated_at": null, "interval_minutes": 10, "nodes": [] }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"retention_days": 90,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"interval_minutes": 10,
|
||||
"window_checks": 72,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"source": "https://paynym.rs/api/v1/nym",
|
||||
"mapping": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"nodes": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"commit": "archipelago-app",
|
||||
"built": null
|
||||
}
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
# Dojo Bay container entrypoint: seeds first-run data, points the backend at
|
||||
# Archipelago's Tor SOCKS proxy, runs the 10-minute prober on a loop (in place
|
||||
# of the systemd timer the standalone deploy used), and supervises all three
|
||||
# processes (node backend, prober loop, nginx) so a SIGTERM from tini/podman
|
||||
# stops them all cleanly rather than leaving orphans for the hard-kill timeout.
|
||||
set -eu
|
||||
|
||||
# ---- first-run data seeding -------------------------------------------------
|
||||
# /app/data is a bind-mounted, host-persistent volume: empty on first install,
|
||||
# and shadows whatever was baked into the image at that path. Populate it from
|
||||
# the clean templates exactly once; a real seed.json/operator.json (once the
|
||||
# claim wizard or "Manage my Dojo" writes one) is never overwritten.
|
||||
for f in seed.json dojos.json history.json history-daily.json paynym-codes.json version.json; do
|
||||
if [ ! -f "/app/data/$f" ]; then
|
||||
cp "/app/data-template/$f" "/app/data/$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- outbound Tor -----------------------------------------------------------
|
||||
# The manifest generates /app/data/tor-proxy.conf with the archy-net bridge
|
||||
# gateway's SOCKS address (Archipelago's Tor binds a second SocksPort there
|
||||
# specifically for containers) — see docs/app-developer-guide.md's
|
||||
# {{NETWORK_GATEWAY}} placeholder. probe.mjs already reads TOR_SOCKS_HOST/PORT
|
||||
# (used for PayNym lookups, DNS-over-HTTPS domain checks, and probing every
|
||||
# listed Dojo), so no code change is needed, only wiring the env vars here.
|
||||
if [ -f /app/data/tor-proxy.conf ]; then
|
||||
TOR_PROXY_ADDR="$(cat /app/data/tor-proxy.conf)"
|
||||
export TOR_SOCKS_HOST="${TOR_PROXY_ADDR%:*}"
|
||||
export TOR_SOCKS_PORT="${TOR_PROXY_ADDR##*:}"
|
||||
fi
|
||||
|
||||
# ---- the backend -------------------------------------------------------------
|
||||
cd /app/server
|
||||
node index.mjs &
|
||||
NODE_PID=$!
|
||||
|
||||
# ---- the 10-minute prober ----------------------------------------------------
|
||||
# Replaces dojobay-update.timer: the same script, invoked on a loop instead of
|
||||
# by systemd. update.mjs itself is unchanged from upstream. Runs once shortly
|
||||
# after start (dojobay-update.timer's OnBootSec=2min counterpart — a fresh
|
||||
# install should not sit on an empty/stale list for a full ten minutes), then
|
||||
# every 10 minutes; a few seconds of random jitter on each wait, same reasoning
|
||||
# as the timer's RandomizedDelaySec (a fleet of instances should not all probe
|
||||
# the same nodes on the same wall-clock tick).
|
||||
(
|
||||
sleep "$((25 + RANDOM % 30))"
|
||||
while true; do
|
||||
node /app/scripts/update.mjs || echo "[update] cycle failed, will retry in 10 minutes" >&2
|
||||
sleep "$((570 + RANDOM % 60))"
|
||||
done
|
||||
) &
|
||||
UPDATE_LOOP_PID=$!
|
||||
|
||||
# ---- the web server -----------------------------------------------------------
|
||||
# Backgrounded rather than exec'd: this script stays the live PID tini
|
||||
# supervises, so the trap below can actually run when SIGTERM arrives and
|
||||
# forward it to all three children. (exec'ing nginx here would replace this
|
||||
# script's process image, and a trap registered by a process that no longer
|
||||
# exists never fires — the other two would then only die on the container's
|
||||
# hard-kill timeout instead of shutting down cleanly.)
|
||||
# -e /dev/stderr: nginx's master process logs its very first startup lines
|
||||
# (before it has even parsed nginx.conf's own error_log directive) to a
|
||||
# compiled-in default path under /var/lib/nginx/logs — a symlink to
|
||||
# /var/log/nginx, which is not one of the paths this app asks Archipelago to
|
||||
# make writable under security.readonly_root. Overriding it here means
|
||||
# nothing ever depends on /var/log/nginx existing or being writable at all,
|
||||
# on this image or any other readonly-root host.
|
||||
nginx -e /dev/stderr -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
cleanup() {
|
||||
kill -TERM "$NGINX_PID" "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true
|
||||
wait "$NGINX_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait "$NGINX_PID"
|
||||
kill "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="114" fill="#0b0b0c"/>
|
||||
<g transform="translate(24,-58.5) scale(1.45)">
|
||||
<g fill="#b5302a">
|
||||
<path d="M40 96 Q160 112 280 96 L280 116 Q160 132 40 116 Z"/>
|
||||
<path d="M154 116 H166 V124 H154 Z"/>
|
||||
<path d="M74 124 H246 V144 H74 Z"/>
|
||||
<path d="M104 126 H124 L118 250 H98 Z"/>
|
||||
<path d="M196 126 H216 L222 250 H202 Z"/>
|
||||
</g>
|
||||
<g stroke="#d6534a" stroke-width="14" stroke-linecap="round" fill="none">
|
||||
<path d="M50 272 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0"/>
|
||||
<path d="M50 300 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".72"/>
|
||||
<path d="M50 328 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".48"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<!-- Onion-Location advertises a .onion to clearnet visitors. Irrelevant while
|
||||
onion-only; when a clearnet domain exists, set it in nginx (see deploy/). -->
|
||||
<title>The Dojo Bay — Public Dojo Directory</title>
|
||||
<meta name="description" content="A community directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets. All nodes reachable over Tor." />
|
||||
|
||||
<!-- Open Graph / Twitter link-preview tags live on the clearnet mirror
|
||||
(dojobay.org), which is the front door that introduces this onion service
|
||||
to new users. Tor clients never render social previews and clearnet
|
||||
crawlers cannot fetch a relative og:image over .onion, so the tags and the
|
||||
og-image.png asset were dead weight here and have been removed. -->
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="assets/icons/192x192.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dojo Bay" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- self-hosted fonts (no external CDN) -->
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/hanken-grotesk.woff2" crossorigin />
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/archivo.woff2" crossorigin />
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/jetbrains-mono.woff2" crossorigin />
|
||||
<link rel="stylesheet" href="assets/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="max-width:640px;margin:14vh auto;padding:0 22px;font-family:sans-serif;color:#f4f4f3">
|
||||
<h1 style="font-size:22px">JavaScript is required</h1>
|
||||
<p style="color:#a0a0a0;line-height:1.7">This directory renders its node list, pairing QR codes and status
|
||||
client-side. Enable JavaScript for this site (in Tor Browser, the "Safest" security level blocks it),
|
||||
or fetch the raw data directly at <code style="color:#e6a39b">data/dojos.json</code>.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<!-- vendored, dependency-free QR encoder (qrcode-generator, MIT) -->
|
||||
<script src="assets/js/qrcode.js"></script>
|
||||
<!-- tiny markdown renderer for content/*.md -->
|
||||
<script src="assets/js/markdown.js"></script>
|
||||
<!-- directory UI -->
|
||||
<script src="assets/js/app.js"></script>
|
||||
|
||||
<!-- PWA: register the service worker (no-op if unsupported) -->
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "The Dojo Bay",
|
||||
"short_name": "Dojo Bay",
|
||||
"description": "A community directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets. All nodes reachable over Tor.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Dojo Bay, containerized for Archipelago.
|
||||
#
|
||||
# Adapted from the upstream project's deploy/nginx-onion.conf.example. The
|
||||
# Tor hidden service, TLS-equivalent framing and moderation-queue trust
|
||||
# decisions all belong to Archipelago's app gate now (it fronts every gated
|
||||
# port with its own onion, strips clickjacking headers for iframe embedding,
|
||||
# and enforces the manifest's auth policy) — this file keeps only what is
|
||||
# still this app's own job: serving the static directory site and proxying
|
||||
# its self-service API to the Node backend running in the same container.
|
||||
worker_processes 1;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css text/javascript application/javascript application/json image/svg+xml text/markdown;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /app;
|
||||
index index.html;
|
||||
|
||||
# The directory data is rewritten every 10 minutes by scripts/update.mjs —
|
||||
# keep it fresh rather than letting a browser cache it for a day like the
|
||||
# other static assets below.
|
||||
location /data/ {
|
||||
add_header Cache-Control "max-age=60";
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Code and markup must revalidate so an image update shows up immediately.
|
||||
location ~* \.(html|js|css|md)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Large, rarely-changing assets can be cached for a day.
|
||||
location ~* \.(woff2|png|svg|ico)$ {
|
||||
add_header Cache-Control "max-age=86400";
|
||||
}
|
||||
|
||||
# --- self-service backend (Auth47 submission API) ---
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 45s; # the connection gate + PayNym lookup probe Tor
|
||||
}
|
||||
|
||||
# SECURITY: the backend's own source and store (sessions, payment codes,
|
||||
# node API keys) live under server/ inside the web root. Never serve it.
|
||||
location ^~ /server/ { return 404; }
|
||||
|
||||
# Serve the SPA shell for the admin route (client-side view; auth is
|
||||
# enforced by the backend, this only returns the same HTML/JS).
|
||||
location = /admin { try_files /index.html =404; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env node
|
||||
// Bootstrap a new Dojo Bay from a TRUSTED existing instance, so a fresh
|
||||
// directory is mature the moment it starts: its nodes become approved store
|
||||
// records here and their reliability histories carry over.
|
||||
//
|
||||
// node scripts/bootstrap-import.mjs --onion <56-char>.onion \
|
||||
// --code PM8T... [--dry-run]
|
||||
//
|
||||
// Trust is verified before anything is imported: the remote instance's
|
||||
// data/operator.json must bind that onion to exactly the payment code YOU
|
||||
// typed in, under a valid wallet signature (server/crypto.ts). If the
|
||||
// signature does not verify, or binds a different onion or code, nothing is
|
||||
// fetched further. After that: dojos.json supplies the nodes, both history
|
||||
// files supply the record, and each PayNym is resolved against paynym.rs
|
||||
// (over Tor) for its full BIP47 code-variant set so imported operators can
|
||||
// sign in here with either variant. Existing ids are never touched; history
|
||||
// is only written for ids that have none.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { httpOverTor } from "./update.mjs";
|
||||
import { store, hasSignedBlock } from "../server/store.ts";
|
||||
import { verifySignedPayload, canonicalPairing } from "../server/crypto.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
|
||||
const defaultCfg = () => ({
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
});
|
||||
|
||||
// GET a JSON document from the remote instance over Tor.
|
||||
async function torFetchJSON(onionHost, urlPath, cfg, timeoutMs = 30000) {
|
||||
const req = `GET ${urlPath} HTTP/1.0\r\nHost: ${onionHost}\r\nUser-Agent: dojobay-bootstrap\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, onionHost, 80, req, timeoutMs);
|
||||
if (res.status !== 200) throw new Error(`${urlPath}: HTTP ${res.status || "no response"}`);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take; see server/build-public.ts. The
|
||||
// counter matters as well as the pid: one import writes the seed, both history
|
||||
// files and the avatars in quick succession.
|
||||
let tmpSeq = 0;
|
||||
async function writeJSONAtomic(p, obj) {
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
}
|
||||
|
||||
// fetchers are injectable for the self-test: fetchDoc(urlPath) -> object,
|
||||
// fetchCodes(paynymOrCode) -> [{code, segwit}, ...]
|
||||
/**
|
||||
* @param {{ onionHost?: string, trustedCode?: string, dryRun?: boolean, dataDir?: string,
|
||||
* log?: (...a: any[]) => void, fetchDoc?: any, fetchCodes?: any,
|
||||
* status?: "approved" | "pending" }} [opts]
|
||||
*/
|
||||
export async function bootstrapImport({
|
||||
onionHost, trustedCode, dryRun = false, dataDir = DATA_DIR, log = console.error,
|
||||
fetchDoc, fetchCodes, status = "approved",
|
||||
} = {}) {
|
||||
const cfg = defaultCfg();
|
||||
fetchDoc = fetchDoc || ((p) => torFetchJSON(onionHost, p, cfg));
|
||||
if (!fetchCodes) {
|
||||
const { fetchNymCodes } = await import("../server/paynym.mjs");
|
||||
fetchCodes = (nym) => fetchNymCodes(nym);
|
||||
}
|
||||
|
||||
// 1) trust gate: the remote operator binding must verify for THIS onion and
|
||||
// exactly the payment code the operator typed in.
|
||||
const { verifyOperatorDoc } = await import("../server/crypto.ts");
|
||||
const opDoc = await fetchDoc("/data/operator.json");
|
||||
const v = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` });
|
||||
if (!v.ok) throw new Error(`refusing to import: remote operator binding does not verify (${v.error})`);
|
||||
if (opDoc.paymentCode !== trustedCode) {
|
||||
throw new Error("refusing to import: the remote instance is operated by a DIFFERENT payment code than the one you trusted");
|
||||
}
|
||||
log(`trusted: ${onionHost} is signed by ${trustedCode.slice(0, 12)}… ✓`);
|
||||
|
||||
// 2) data
|
||||
const dojos = await fetchDoc("/data/dojos.json");
|
||||
const hist = await fetchDoc("/data/history.json").catch(() => ({ nodes: {} }));
|
||||
const daily = await fetchDoc("/data/history-daily.json").catch(() => ({ nodes: {} }));
|
||||
const nodes = (dojos.nodes || []).filter((n) => n.payload?.pairing?.url);
|
||||
|
||||
// The pairing URL identifies a physical Dojo; an id does not.
|
||||
//
|
||||
// An operator installing a new instance names their own node in the anchor,
|
||||
// then bootstraps from a directory that already lists it. The two ids differ,
|
||||
// because each instance derives one from the name it was given, so the same
|
||||
// machine arrived twice: once as the anchor and once as an import, with its
|
||||
// reliability history split between them. What is actually the same thing is
|
||||
// the onion address in the signed pairing payload, which is why matching on
|
||||
// it is not a heuristic. Two listings cannot share one, and an operator
|
||||
// cannot claim somebody else's without the signature failing.
|
||||
//
|
||||
// Compared as a whole URL rather than by host alone, because one machine may
|
||||
// legitimately serve mainnet at /v2 and testnet at /test/v2, and those are
|
||||
// two listings. Lower-cased and stripped of a trailing slash, since neither
|
||||
// changes which endpoint is meant.
|
||||
const pairingKey = (n) => {
|
||||
const u = n?.payload?.pairing?.url;
|
||||
if (typeof u !== "string" || !u) return null;
|
||||
return u.trim().toLowerCase().replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
// Everything this instance already lists, from the store AND from the seed
|
||||
// anchor. The anchor is not a store record, which is exactly why it was
|
||||
// invisible to this check and why the operator's own node was the one node
|
||||
// guaranteed to duplicate.
|
||||
const localByUrl = new Map();
|
||||
for (const r of await store.listSubmissions()) {
|
||||
const k = pairingKey(r);
|
||||
if (k) localByUrl.set(k, r.id);
|
||||
}
|
||||
try {
|
||||
const seed = JSON.parse(await readFile(path.join(dataDir, "seed.json"), "utf8"));
|
||||
for (const n of seed.nodes || []) {
|
||||
const k = pairingKey(n);
|
||||
if (k && !localByUrl.has(k)) localByUrl.set(k, n.id);
|
||||
}
|
||||
} catch { /* no anchor yet, which is normal on a bare install */ }
|
||||
|
||||
// 3) plan records: skip existing ids; resolve full code sets per PayNym
|
||||
const existingIds = new Set((await store.listSubmissions()).map((r) => r.id));
|
||||
const plan = [];
|
||||
const codeCache = new Map();
|
||||
for (const n of nodes) {
|
||||
if (existingIds.has(n.id)) { plan.push({ action: "skip", n }); continue; }
|
||||
// Same machine under a different id. The record is not created, because a
|
||||
// second listing for one Dojo is worse than a missing one, but the history
|
||||
// is worth having: it is the same node's record of itself, and dropping it
|
||||
// would restart an operator's reliability figures from nothing on a machine
|
||||
// that has been up for months. Carried onto the id this instance uses.
|
||||
const dupOf = localByUrl.get(pairingKey(n));
|
||||
if (dupOf) { plan.push({ action: "merge", n, dupOf }); continue; }
|
||||
// A published node from another instance carries its signed block in
|
||||
// dojos.json, so an unsigned one either predates the rule there or was
|
||||
// published by an instance that does not enforce it. Either way it cannot
|
||||
// enter this store, and saying so in the plan is better than a throw from
|
||||
// putSubmission half way through the import.
|
||||
if (!hasSignedBlock(n)) { plan.push({ action: "refuse", n, why: "no signed pairing block" }); continue; }
|
||||
// And the block must actually verify, here, against the payload it claims
|
||||
// to cover.
|
||||
//
|
||||
// hasSignedBlock only looks for the two header lines, and putSubmission
|
||||
// enforces nothing more, so until this check an imported listing's
|
||||
// signature was taken on the source instance's word: a directory that was
|
||||
// careless or compromised could publish a well-formed block that verifies
|
||||
// against nothing, and every instance bootstrapping from it would list the
|
||||
// node. This is the same standard the domain badges above are already held
|
||||
// to, and for the same reason: one compromised directory must not be able
|
||||
// to place listings across a federation.
|
||||
//
|
||||
// Offline and self-contained. canonicalPairing derives the message from the
|
||||
// payload being imported, so a payload altered in transit no longer matches
|
||||
// what was signed, and the addresses come from the payment code named
|
||||
// inside the block itself rather than from anything the source asserts.
|
||||
const sig = verifySignedPayload({
|
||||
signedText: n.signed,
|
||||
expectedMessage: canonicalPairing(n.payload),
|
||||
network: n.network === "testnet" ? "testnet" : "bitcoin",
|
||||
});
|
||||
if (!sig.ok) { plan.push({ action: "refuse", n, why: `signature does not verify (${sig.error})` }); continue; }
|
||||
let codes = n.paymentCode ? [n.paymentCode] : [];
|
||||
if (n.paynym) {
|
||||
if (!codeCache.has(n.paynym)) codeCache.set(n.paynym, await fetchCodes(n.paynym).catch(() => []));
|
||||
const all = codeCache.get(n.paynym).map((c) => c.code);
|
||||
if (all.length) codes = [...new Set([...all, ...codes])];
|
||||
}
|
||||
if (!codes.length) { plan.push({ action: "refuse", n, why: "no BIP47 payment code" }); continue; }
|
||||
plan.push({ action: "import", n, codes });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
for (const { action, n, codes, why } of plan) {
|
||||
log(` ${action.padEnd(6)} ${n.id.padEnd(28)} ${n.paynym || "(no PayNym)"} (${(codes || []).length} codes)${why ? " — " + why : ""}`);
|
||||
}
|
||||
const imports = plan.filter((p) => p.action === "import");
|
||||
const merges = plan.filter((p) => p.action === "merge");
|
||||
const refused = plan.filter((p) => p.action === "refuse");
|
||||
for (const m of merges) {
|
||||
log(` merge ${m.n.id.padEnd(28)} same Dojo as ${m.dupOf}: history only, no second listing`);
|
||||
}
|
||||
if (refused.length) log(`refused ${refused.length} node(s) that cannot be listed here: ${refused.map((p) => p.n.id).join(", ")}`);
|
||||
// The plan as data, not as log lines. The command line reads the log; the
|
||||
// admin console has to render this and let an operator decide, and parsing
|
||||
// the log back out would be inventing a format nobody agreed on.
|
||||
const rows = plan.map(({ action, n, codes, dupOf, why }) => ({
|
||||
action, id: n.id, name: n.name || n.id, network: n.network || null,
|
||||
paynym: n.paynym || null, url: n?.payload?.pairing?.url || null,
|
||||
codes: (codes || []).length, dupOf: dupOf || null, why: why || null,
|
||||
}));
|
||||
if (dryRun) {
|
||||
log(`dry run: ${imports.length} node(s) would be imported`
|
||||
+ (merges.length ? `, ${merges.length} recognised as already listed here` : "")
|
||||
+ ", nothing written.");
|
||||
return { imported: 0, planned: imports.length, merged: merges.length,
|
||||
refused: refused.length, plan: rows, status };
|
||||
}
|
||||
|
||||
for (const { n, codes } of imports) {
|
||||
await store.putSubmission({
|
||||
id: n.id, network: n.network, name: n.name || n.id,
|
||||
paymentCodes: codes, paynym: n.paynym || null,
|
||||
jurisdiction: n.jurisdiction || null, country: n.country || null,
|
||||
hardware: n.hardware || null, payload: n.payload,
|
||||
signed: n.signed || null,
|
||||
// approved at install, because choosing to bootstrap from a directory IS
|
||||
// the decision to trust its list. An import into a running instance
|
||||
// arrives pending instead, so it lands in the moderation queue the
|
||||
// operator already uses and nothing is published until they say so.
|
||||
status, source: `bootstrap-import:${onionHost}`,
|
||||
created_at: now, updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// 3b) verified operator domains.
|
||||
//
|
||||
// dojos.json publishes each badge's proof, and the signed statement is
|
||||
// deliberately portable: it names the domain and the payment code, never the
|
||||
// instance that verified it. So a claim travels intact — but it is NOT taken
|
||||
// on the source's word. We re-verify the signature here, locally and offline,
|
||||
// and store the claim UNVERIFIED so this instance's own sweep must see the TXT
|
||||
// record with its own eyes before any badge appears. Importing a badge because
|
||||
// another instance said so would make one compromised directory able to mint
|
||||
// verified domains across a federation.
|
||||
const claims = new Map();
|
||||
for (const n of dojos.nodes || []) {
|
||||
const pf = n.operator_domain_proof;
|
||||
if (!pf || !pf.domain || !pf.paymentCode || !pf.signed) continue;
|
||||
if (claims.has(pf.paymentCode)) continue;
|
||||
claims.set(pf.paymentCode, pf);
|
||||
}
|
||||
let domainsImported = 0, domainsRefused = 0;
|
||||
if (claims.size) {
|
||||
const { verifySignedUrlClaim } = await import("../server/crypto.ts");
|
||||
for (const [code, pf] of claims) {
|
||||
if (await store.getDomain(code)) continue; // never overwrite a local claim
|
||||
const v = verifySignedUrlClaim({ signed: pf.signed, expectedUrl: `https://${pf.domain}`, paymentCode: code });
|
||||
if (!v.ok) {
|
||||
log(` domain ${pf.domain}: refused (${v.error})`);
|
||||
domainsRefused++;
|
||||
continue;
|
||||
}
|
||||
await store.putDomain({
|
||||
paymentCode: code, domain: pf.domain, signed: pf.signed,
|
||||
verified: false, // this instance has not seen the DNS yet
|
||||
verified_at: null,
|
||||
last_check: null, // so the sweep picks it up immediately
|
||||
last_result: `imported from ${onionHost}; awaiting our own DNS check`,
|
||||
fail_since: null, created_at: now,
|
||||
});
|
||||
log(` domain ${pf.domain}: signature verified, awaiting our own TXT lookup`);
|
||||
domainsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) histories: only for ids we have no history for
|
||||
for (const [file, remote] of [["history.json", hist], ["history-daily.json", daily]]) {
|
||||
const p = path.join(dataDir, file);
|
||||
let local; try { local = JSON.parse(await readFile(p, "utf8")); } catch { local = { nodes: {} } }
|
||||
local.nodes = local.nodes || {};
|
||||
let added = 0;
|
||||
for (const [id, entry] of Object.entries(remote.nodes || {})) {
|
||||
if (!local.nodes[id] && imports.some((x) => x.n.id === id)) { local.nodes[id] = entry; added++; continue; }
|
||||
// A duplicate contributes its history under the id this instance uses.
|
||||
//
|
||||
// The two series are combined rather than one replacing the other. An
|
||||
// anchor installed an hour ago has a handful of checks of its own and the
|
||||
// remote has months: overwriting throws away the local ones, skipping
|
||||
// throws away the months, and neither is what an operator means by
|
||||
// importing history. Combined, de-duplicated on the timestamp, sorted,
|
||||
// and trimmed to the same window the updater keeps.
|
||||
const merged = merges.find((x) => x.n.id === id);
|
||||
if (!merged) continue;
|
||||
const key = entry.checks ? "checks" : "days";
|
||||
const stamp = key === "checks" ? "t" : "d";
|
||||
const mine = (local.nodes[merged.dupOf] || {})[key] || [];
|
||||
const theirs = entry[key] || [];
|
||||
if (!theirs.length) continue;
|
||||
const byStamp = new Map();
|
||||
// Local last, so a period this instance measured itself wins over the
|
||||
// remote's account of the same period.
|
||||
for (const row of [...theirs, ...mine]) if (row && row[stamp]) byStamp.set(row[stamp], row);
|
||||
const all = [...byStamp.values()].sort((x, y) => String(x[stamp]).localeCompare(String(y[stamp])));
|
||||
const cap = key === "checks" ? (remote.window_checks || local.window_checks || 144) : 90;
|
||||
local.nodes[merged.dupOf] = { [key]: all.slice(-cap) };
|
||||
added++;
|
||||
}
|
||||
if (added) {
|
||||
if (remote.interval_minutes && !local.interval_minutes) local.interval_minutes = remote.interval_minutes;
|
||||
if (remote.window_checks && !local.window_checks) local.window_checks = remote.window_checks;
|
||||
await writeJSONAtomic(p, local);
|
||||
log(` history: ${added} node(s) carried into ${file}`);
|
||||
}
|
||||
}
|
||||
log(`imported ${imports.length} node(s) from ${onionHost}`
|
||||
+ (merges.length ? `, and recognised ${merges.length} as node(s) this instance already lists` : "")
|
||||
+ ". Now run: node server/build-public.mjs");
|
||||
return { imported: imports.length, planned: imports.length, merged: merges.length,
|
||||
refused: refused.length, plan: rows, status,
|
||||
domains_imported: domainsImported, domains_refused: domainsRefused };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const arg = (k) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : null; };
|
||||
const onionHost = String(arg("--onion") || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
const trustedCode = arg("--code");
|
||||
if (!/^[a-z2-7]{56}\.onion$/.test(onionHost) || !trustedCode) {
|
||||
console.error("usage: node scripts/bootstrap-import.mjs --onion <56-char>.onion --code PM8T... [--dry-run]");
|
||||
process.exit(1);
|
||||
}
|
||||
bootstrapImport({ onionHost, trustedCode, dryRun: process.argv.includes("--dry-run") })
|
||||
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// Move seed nodes into the operator-managed store, idempotently.
|
||||
//
|
||||
// node scripts/migrate-seed-to-store.mjs --dry-run print the plan, write nothing
|
||||
// node scripts/migrate-seed-to-store.mjs apply it
|
||||
//
|
||||
// The seed's role is the instance ANCHOR: exactly one node, the instance
|
||||
// operator's own Dojo (mainnet or testnet), carrying their PayNym and BIP47
|
||||
// payment code. Everything else belongs in the store, where operators manage
|
||||
// their listings over Auth47. This script is the transition tool for an
|
||||
// instance whose seed still carries an old-style curated list:
|
||||
//
|
||||
// - a seed node with a PayNym present in data/paynym-codes.json becomes an
|
||||
// APPROVED store record owned by every BIP47 code variant of that PayNym
|
||||
// - a seed node WITHOUT a PayNym is REFUSED. Every listing must carry a BIP47
|
||||
// payment code: it is the identity a listing is owned, edited, verified and
|
||||
// recognised by. Code-less records were once adopted as admin-managed
|
||||
// exceptions; that door is closed, and the store refuses to write one.
|
||||
// - a seed node whose id already exists in the store is SKIPPED untouched,
|
||||
// which is what makes re-runs no-ops and lets the anchor node coexist as
|
||||
// both seed entry (bootstrap guarantee) and store record (Auth47-managed:
|
||||
// the store record shadows the seed copy in the public list)
|
||||
//
|
||||
// The script never rewrites data/seed.json: slimming the seed down to the
|
||||
// anchor is a deliberate, separate commit made AFTER the store records exist,
|
||||
// because a deploy that removes a node's seed entry before its store record
|
||||
// exists delists it (the history survives under the fourteen-day grace stamp,
|
||||
// but there is no reason to invite the gap).
|
||||
//
|
||||
// Record ids are the original seed ids, so reliability history (keyed by id)
|
||||
// carries over untouched. Afterwards run `node server/build-public.mjs`.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { store, hasSignedBlock } from "../server/store.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
const SEED_PATH = path.join(DATA_DIR, "seed.json");
|
||||
const CODES_PATH = path.join(DATA_DIR, "paynym-codes.json");
|
||||
const DRY = process.argv.includes("--dry-run");
|
||||
|
||||
async function readJSON(p, fallback) {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); }
|
||||
catch (e) { if (fallback !== undefined) return fallback; throw e; }
|
||||
}
|
||||
|
||||
const slugOf = (v) => String(v || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
|
||||
// Name derivation for owned groups. Remainder = seed id minus `${network}-`.
|
||||
// When one owner's several nodes share a first hyphen-token and stripping it
|
||||
// leaves something for each, drop the shared token; and prefer the seed's
|
||||
// display name whenever it slugs to the derived value, so capitalisation like
|
||||
// "wanderinKing072" survives.
|
||||
function deriveNames(nodes) {
|
||||
const rem = nodes.map((n) => n.id.replace(new RegExp(`^${n.network}-`), ""));
|
||||
let names = rem;
|
||||
if (nodes.length > 1) {
|
||||
const first = rem.map((r) => r.split("-")[0]);
|
||||
if (first.every((t) => t === first[0]) && rem.every((r) => r.includes("-"))) {
|
||||
names = rem.map((r) => r.split("-").slice(1).join("-"));
|
||||
}
|
||||
}
|
||||
return nodes.map((n, i) => (n.name && slugOf(n.name) === names[i]) ? n.name : names[i]);
|
||||
}
|
||||
|
||||
function toRecord(n, name, codes, now) {
|
||||
return {
|
||||
id: n.id, network: n.network, name,
|
||||
paymentCodes: codes,
|
||||
paynym: n.paynym || null,
|
||||
jurisdiction: n.jurisdiction || null,
|
||||
country: n.country || null,
|
||||
hardware: n.hardware || null,
|
||||
payload: n.payload,
|
||||
signed: n.signed || null,
|
||||
status: "approved",
|
||||
source: "seed-migration",
|
||||
created_at: now, updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seed = await readJSON(SEED_PATH);
|
||||
const mapping = (await readJSON(CODES_PATH, { mapping: {} })).mapping || {};
|
||||
const existing = await store.listSubmissions();
|
||||
const nodes = seed.nodes || [];
|
||||
|
||||
const owned = nodes.filter((n) => n.paynym);
|
||||
const missing = owned.filter((n) => !mapping[n.paynym]);
|
||||
if (missing.length) {
|
||||
console.error("aborting: no payment codes in", path.relative(ROOT, CODES_PATH), "for:");
|
||||
for (const n of missing) console.error(" ", n.id, n.paynym);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive names per owner; code-less nodes keep their seed name (or the id
|
||||
// remainder). Then refuse any per-network name collision against the plan
|
||||
// itself or records already in the store under a DIFFERENT id.
|
||||
const byOwner = new Map();
|
||||
for (const n of owned) (byOwner.get(n.paynym) || byOwner.set(n.paynym, []).get(n.paynym)).push(n);
|
||||
const nameOf = new Map();
|
||||
for (const group of byOwner.values()) deriveNames(group).forEach((nm, i) => nameOf.set(group[i].id, nm));
|
||||
for (const n of nodes.filter((x) => !x.paynym)) {
|
||||
const rem = n.id.replace(new RegExp(`^${n.network}-`), "");
|
||||
nameOf.set(n.id, (n.name && slugOf(n.name) === rem) ? n.name : (n.name || rem));
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const n of nodes) {
|
||||
const key = `${n.network}:${slugOf(nameOf.get(n.id))}`;
|
||||
if (seen.has(key)) { console.error("aborting: duplicate node name per network:", key); process.exit(1); }
|
||||
seen.add(key);
|
||||
}
|
||||
for (const r of existing) {
|
||||
for (const n of nodes) {
|
||||
if (r.id !== n.id && r.network === n.network && slugOf(r.name) === slugOf(nameOf.get(n.id))) {
|
||||
console.error(`aborting: seed node ${n.id} clashes with store record ${r.id} on name "${r.name}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const byId = new Map(existing.map((r) => [r.id, r]));
|
||||
const plan = nodes.map((n) => {
|
||||
if (byId.has(n.id)) return { action: "skip", why: "already in store (left untouched)", node: byId.get(n.id) };
|
||||
const codes = n.paynym ? mapping[n.paynym].codes.map((c) => c.code) : [];
|
||||
// Two things make a node unmigratable, and both are the store's rules
|
||||
// rather than this script's: no payment code means no owner, and no signed
|
||||
// pairing block means nothing a visitor can check. Refusing here rather
|
||||
// than letting putSubmission throw is what turns a stack trace part-way
|
||||
// through a migration into a plan you can read before anything is written.
|
||||
const node = toRecord(n, nameOf.get(n.id), codes, now);
|
||||
if (!codes.length) return { action: "refuse", why: "no BIP47 payment code", node };
|
||||
if (!hasSignedBlock(node)) return { action: "refuse", why: "no signed pairing block", node };
|
||||
return { action: "create", node };
|
||||
});
|
||||
|
||||
console.log(`${DRY ? "DRY RUN — " : ""}migration plan (${nodes.length} seed nodes):`);
|
||||
for (const { action, why, node } of plan) {
|
||||
const owner = node.paynym || "(no PayNym)";
|
||||
console.log(` ${action.padEnd(6)} ${node.id.padEnd(26)} name=${String(node.name).padEnd(18)} ${owner} (${(node.paymentCodes || []).length} codes)${why ? " — " + why : ""}`);
|
||||
if (action === "refuse") {
|
||||
console.log(` REFUSED: ${node.id} ${why}, so it cannot be migrated.`);
|
||||
console.log(` Give it a PayNym in data/paynym-codes.json and a signed pairing block, or drop it from the seed.`);
|
||||
}
|
||||
}
|
||||
|
||||
const changes = plan.filter((p) => p.action === "create");
|
||||
const refused = plan.filter((p) => p.action === "refuse");
|
||||
const tail = refused.length ? ` ${refused.length} refused: ${refused.map((p) => p.node.id).join(", ")}.` : "";
|
||||
if (DRY) { console.log(`\ndry run: ${changes.length} change(s) would be made, nothing written.${tail}`); return; }
|
||||
if (!changes.length) { console.log(`\nnothing to do: every seed node already has a store record.${tail}`); return; }
|
||||
for (const { node } of changes) await store.putSubmission(node);
|
||||
console.log(`\napplied ${changes.length} change(s).${tail} Now run: node server/build-public.mjs`);
|
||||
console.log("Once the store records exist, slim data/seed.json to the anchor (your own node) in a separate commit.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main().catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// Pack this instance's own codebase into data/dojobay-src.zip, so the running
|
||||
// site is its own distribution point: visitors download exactly the code the
|
||||
// instance runs (the footer's source icon), with no reliance on GitHub being
|
||||
// reachable. Node builtins only -- the ZIP container is written by hand
|
||||
// (deflate entries via zlib + a central directory), because a bare box has no
|
||||
// `zip` binary and scripts/ must run everywhere.
|
||||
//
|
||||
// node scripts/pack-source.mjs write data/dojobay-src.zip
|
||||
//
|
||||
// What goes in is manifest-driven, and what stays out matters more than what
|
||||
// goes in: NEVER the submission store (Dojo API keys, sessions), never the
|
||||
// instance's generated data (dojos.json, history, avatars), and never its
|
||||
// identity (seed.json anchor, operator.json binding, paynym-codes.json), so
|
||||
// extracting the zip over an existing web root upgrades the CODE and touches
|
||||
// nothing the instance owns. data/version.json IS included: it states which
|
||||
// commit the code is, which is exactly what a downloader wants to know.
|
||||
import { readFile, writeFile, rename, readdir, stat, mkdir } from "node:fs/promises";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PREFIX = "dojobay/"; // extraction lands in one folder
|
||||
|
||||
const INCLUDE_FILES = [
|
||||
"index.html", "manifest.json", "sw.js", "favicon.svg", "og-image.png",
|
||||
// LICENSE travels with THIRD-PARTY-NOTICES.md: the archive is a distributed
|
||||
// copy of the source, and the README it contains links to the notices.
|
||||
// SECURITY.md travels for the same reason: a recipient who finds a
|
||||
// vulnerability in this copy needs to be told where to send it.
|
||||
"LICENSE", "THIRD-PARTY-NOTICES.md", "README.md", "CONTRIBUTING.md", "SECURITY.md", "package.json",
|
||||
"tsconfig.json", "types.d.ts",
|
||||
"install.sh", "uninstall.sh",
|
||||
"data/version.json",
|
||||
];
|
||||
// docs/ holds the reasoning: why things are shaped as they are and what was
|
||||
// tried and rejected. It is the most useful thing in the tree to anyone
|
||||
// changing the code, and this archive is how a peer instance receives the code.
|
||||
const INCLUDE_DIRS = ["assets", "content", "deploy", "docs", "scripts", "server", ".github"];
|
||||
const DENY = [
|
||||
"server/data", "server/node_modules", "node_modules", ".git",
|
||||
"data/dojos.json", "data/history.json", "data/history-daily.json",
|
||||
"data/avatars", "data/seed.json", "data/operator.json", "data/paynym-codes.json",
|
||||
"data/updates", "data/backups",
|
||||
];
|
||||
const denied = (rel) => DENY.some((d) => rel === d || rel.startsWith(d + "/"))
|
||||
|| rel.endsWith(".zip") || path.basename(rel) === ".DS_Store";
|
||||
|
||||
async function collect(root) {
|
||||
const out = [];
|
||||
for (const f of INCLUDE_FILES) {
|
||||
try { await stat(path.join(root, f)); out.push(f); } catch { /* absent on this instance */ }
|
||||
}
|
||||
async function walk(rel) {
|
||||
for (const e of await readdir(path.join(root, rel), { withFileTypes: true })) {
|
||||
const r = rel + "/" + e.name;
|
||||
if (denied(r)) continue;
|
||||
if (e.isDirectory()) await walk(r);
|
||||
else if (e.isFile()) out.push(r);
|
||||
}
|
||||
}
|
||||
for (const d of INCLUDE_DIRS) {
|
||||
try { await stat(path.join(root, d)); await walk(d); } catch { /* absent */ }
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ---- minimal ZIP writer (PKZIP appnote: local headers + central directory) --
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
const crc32 = (buf) => {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const dosTime = (d) => (((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff);
|
||||
const dosDate = (d) => ((((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff);
|
||||
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n & 0xffff); return b; };
|
||||
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0); return b; };
|
||||
|
||||
function buildZip(entries) { // entries: [{name, data, mtime, mode}]
|
||||
const locals = [], centrals = [];
|
||||
let offset = 0;
|
||||
for (const { name, data, mtime, mode = 0o644 } of entries) {
|
||||
const nameBuf = Buffer.from(name, "utf8");
|
||||
const deflated = deflateRawSync(data, { level: 9 });
|
||||
const stored = deflated.length < data.length;
|
||||
const body = stored ? deflated : data;
|
||||
const method = stored ? 8 : 0;
|
||||
const crc = crc32(data);
|
||||
const t = u16(dosTime(mtime)), dt = u16(dosDate(mtime));
|
||||
const common = Buffer.concat([
|
||||
u16(20), u16(0x0800 /* UTF-8 names */), u16(method), t, dt,
|
||||
u32(crc), u32(body.length), u32(data.length), u16(nameBuf.length), u16(0),
|
||||
]);
|
||||
locals.push(Buffer.concat([u32(0x04034b50), common, nameBuf, body]));
|
||||
centrals.push(Buffer.concat([
|
||||
u32(0x02014b50), u16((3 << 8) | 20 /* unix */), common, u16(0), u16(0), u16(0),
|
||||
u32(((0o100000 | mode) >>> 0) * 0x10000) /* unix mode in high word */, u32(offset), nameBuf,
|
||||
]));
|
||||
offset += locals[locals.length - 1].length;
|
||||
}
|
||||
const cd = Buffer.concat(centrals);
|
||||
const end = Buffer.concat([
|
||||
u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length),
|
||||
u32(cd.length), u32(offset), u16(0),
|
||||
]);
|
||||
return Buffer.concat([...locals, cd, end]);
|
||||
}
|
||||
|
||||
export async function packSource({ root = ROOT, outDir = path.join(ROOT, "data") } = {}) {
|
||||
const files = await collect(root);
|
||||
const entries = [];
|
||||
for (const rel of files) {
|
||||
const p = path.join(root, rel);
|
||||
const [data, st] = [await readFile(p), await stat(p)];
|
||||
entries.push({ name: PREFIX + rel, data, mtime: st.mtime, mode: st.mode & 0o777 });
|
||||
}
|
||||
const zip = buildZip(entries);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const out = path.join(outDir, "dojobay-src.zip");
|
||||
await writeFile(out + ".tmp", zip);
|
||||
await rename(out + ".tmp", out);
|
||||
return { out, files: files.length, bytes: zip.length };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
packSource().then((r) => console.log(`wrote ${r.out}: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`))
|
||||
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — directory updater
|
||||
//
|
||||
// Probes every node's .onion pairing endpoint over Tor and rewrites the two
|
||||
// JSON databases the website reads:
|
||||
//
|
||||
// data/dojos.json current snapshot -> node.status + node.checked_at
|
||||
// data/history.json rolling history -> one {t, up} per node, per run
|
||||
//
|
||||
// dojos.json is also the source of truth for the node LIST. To add or remove a
|
||||
// node, edit dojos.json (name, paynym, payload, etc.); this script only fills
|
||||
// in status/checked_at and appends to the history. New nodes get a fresh
|
||||
// history series automatically; removed nodes are retired under a grace stamp
|
||||
// and only pruned HISTORY_GRACE_DAYS (default 14) after leaving the list.
|
||||
//
|
||||
// Health is checked through Tor's SOCKS5 proxy (no external npm deps). For a
|
||||
// node whose pairing payload carries an apikey, the check logs in to the Dojo
|
||||
// API and reads info.latest_block.height from GET /v2/wallet: the node is
|
||||
// "active" only if it returns a chain tip, which proves the whole stack (Tor,
|
||||
// nginx, Dojo API, bitcoind) is serving block data, and the height is recorded
|
||||
// on the node. Nodes without an apikey fall back to a plain HTTP reachability
|
||||
// probe (active if the onion returns an HTTP response line).
|
||||
//
|
||||
// Every Dojo response carries its running version in the X-Dojo-Version header;
|
||||
// the probe reads it and records node.detected_version, so a card can show the
|
||||
// live version rather than the one frozen into the pairing payload at signing
|
||||
// time. build-public.mjs decides the effective version an operator override
|
||||
// still wins over it.
|
||||
//
|
||||
// Run once (intended to be driven by cron/systemd every 10 minutes):
|
||||
// node scripts/update.mjs
|
||||
//
|
||||
// Config via environment variables (all optional):
|
||||
// TOR_SOCKS_HOST default 127.0.0.1
|
||||
// TOR_SOCKS_PORT default 9050
|
||||
// DATA_DIR default <repo>/data
|
||||
// TIMEOUT_MS default 45000 per-node Tor timeout
|
||||
// CONCURRENCY default 3 simultaneous Tor circuits
|
||||
// WINDOW_CHECKS default 144 history length kept per node (24h @ 10min)
|
||||
// RETENTION_DAYS default 90 daily-rollup days kept per node (~3 months)
|
||||
// CONNECT_ONLY default 0 "1" = treat a successful Tor connect as up
|
||||
// without waiting for an HTTP response line
|
||||
// DOJO_VERSION_HEADER default X-Dojo-Version response header carrying the
|
||||
// node's running Dojo version
|
||||
// =============================================================================
|
||||
|
||||
import net from "node:net";
|
||||
import { retireUnlisted } from "../server/build-public.ts";
|
||||
import { readFile, writeFile, rename, stat as fsStat, mkdir as fsMkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Chosen for a home connection as much as a VPS, because the unit that would
|
||||
// override them lives in /etc and no update can reach it. A node answering at
|
||||
// 23 seconds was being recorded as down against a 30 second ceiling, and six
|
||||
// circuits at once through one Tor client on a domestic line makes every probe
|
||||
// slow together, which reads as every node being down.
|
||||
export const DEFAULT_TIMEOUT_MS = 45000;
|
||||
export const DEFAULT_CONCURRENCY = 3;
|
||||
|
||||
const CFG = {
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
dataDir: process.env.DATA_DIR || path.resolve(__dirname, "..", "data"),
|
||||
timeoutMs: +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||
concurrency: +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY),
|
||||
windowChecks: +(process.env.WINDOW_CHECKS || 144),
|
||||
retentionDays: +(process.env.RETENTION_DAYS || 90),
|
||||
connectOnly: process.env.CONNECT_ONLY === "1",
|
||||
// The Dojo API stamps its running version on every response via this header
|
||||
// (Dojo's http-server appends X-Dojo-Version: <DOJO_VERSION_TAG> as global
|
||||
// middleware). Read it during the probe so a node's displayed version tracks
|
||||
// what it is actually running, instead of the value frozen into its pairing
|
||||
// payload at submission time. Overridable in case a fork renames the header.
|
||||
dojoVersionHeader: (process.env.DOJO_VERSION_HEADER || "X-Dojo-Version").toLowerCase(),
|
||||
};
|
||||
|
||||
// ---- SOCKS5 reply codes (RFC 1928 §6) ---------------------------------------
|
||||
const SOCKS_ERR = {
|
||||
0x01: "general failure",
|
||||
0x02: "connection not allowed",
|
||||
0x03: "network unreachable",
|
||||
0x04: "host unreachable", // Tor: onion descriptor not found / service down
|
||||
0x05: "connection refused",
|
||||
0x06: "TTL expired",
|
||||
0x07: "command not supported",
|
||||
0x08: "address type not supported",
|
||||
};
|
||||
|
||||
class SocksError extends Error {
|
||||
constructor(code) {
|
||||
super("SOCKS " + (SOCKS_ERR[code] || "error 0x" + code.toString(16)));
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Open a TCP stream to host:port THROUGH a SOCKS5 proxy (Tor), using a remote
|
||||
// hostname so the .onion is resolved by Tor, not locally. Resolves with a
|
||||
// connected socket on success; rejects on any handshake/connect failure.
|
||||
// -----------------------------------------------------------------------------
|
||||
export function socks5Connect(proxyHost, proxyPort, host, port, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(proxyPort, proxyHost);
|
||||
let stage = "greet";
|
||||
let buf = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
|
||||
const fail = (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
};
|
||||
const timer = setTimeout(() => fail(new Error("timeout")), timeoutMs);
|
||||
|
||||
socket.once("connect", () => {
|
||||
// greeting: VER=5, NMETHODS=1, METHOD=0 (no auth)
|
||||
socket.write(Buffer.from([0x05, 0x01, 0x00]));
|
||||
});
|
||||
socket.on("error", fail);
|
||||
socket.on("close", () => fail(new Error("proxy closed")));
|
||||
|
||||
socket.on("data", (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
|
||||
if (stage === "greet") {
|
||||
if (buf.length < 2) return;
|
||||
if (buf[0] !== 0x05 || buf[1] !== 0x00) return fail(new Error("proxy refused no-auth handshake"));
|
||||
buf = buf.subarray(2);
|
||||
stage = "reply";
|
||||
// CONNECT request with ATYP=3 (domain name), so Tor resolves the onion
|
||||
const hb = Buffer.from(host, "utf8");
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hb.length]),
|
||||
hb,
|
||||
Buffer.from([(port >> 8) & 0xff, port & 0xff]),
|
||||
]));
|
||||
}
|
||||
|
||||
if (stage === "reply") {
|
||||
if (buf.length < 4) return;
|
||||
if (buf[1] !== 0x00) return fail(new SocksError(buf[1]));
|
||||
const atyp = buf[3];
|
||||
const addrLen =
|
||||
atyp === 0x01 ? 4 :
|
||||
atyp === 0x04 ? 16 :
|
||||
atyp === 0x03 ? (buf.length >= 5 ? 1 + buf[4] : Infinity) : 0;
|
||||
if (buf.length < 4 + addrLen + 2) return; // wait for the full bound-addr
|
||||
// success: hand the live stream back to the caller
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.removeAllListeners("data");
|
||||
socket.removeAllListeners("error");
|
||||
socket.removeAllListeners("close");
|
||||
resolve(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Well-formed dummy extended keys, used only to elicit info.latest_block from
|
||||
// the Dojo /wallet endpoint. They are passed as `new` so the node performs no
|
||||
// rescan or historical import; they derive from a throwaway seed and can never
|
||||
// receive funds. One per network so the Dojo never rejects them on format.
|
||||
const DUMMY_XPUB = "xpub661MyMwAqRbcFhv1kNXxwyGrJUVPrmiBNTVDYAtpzF5zu9ceuhn5yV6oaSdveis14LSeBLzpWb58pDNN6hC59TTDyiN7iJR7kUQgXNMfZCL";
|
||||
const DUMMY_TPUB = "tpubD6NzVbkrYhZ4XW6sCZX49tcDdbb3rADEv65WtiwyL9qteSHMyvdB7vmdpUiiBDpErEyYnvWh3guBWPryVZ3K2tuX3K7RPq5MLS16HN9awey";
|
||||
|
||||
// The most bytes a response may accumulate before the read is abandoned.
|
||||
//
|
||||
// Every caller of httpOverTor is talking to a machine somebody else controls:
|
||||
// that is the point of the probe. Without a ceiling the reader accumulates
|
||||
// whatever arrives until the socket closes or the timeout fires, so a listed
|
||||
// node that simply never stops sending can push thirty seconds of Tor
|
||||
// throughput into the heap, times CONCURRENCY parallel probes, on a VPS whose
|
||||
// documented minimum is 1 GB. Nothing about that requires malice: a Dojo
|
||||
// misconfigured to return a file rather than JSON does it by accident.
|
||||
//
|
||||
// 2 MiB is chosen against the largest legitimate response any probe path sees,
|
||||
// which is a Dojo /wallet reply for two dummy xpubs, single-digit kilobytes.
|
||||
// A PayNym avatar is a small PNG and sits under the same ceiling comfortably;
|
||||
// it does not get a tighter limit of its own, because a second constant would
|
||||
// have to be kept in a sensible relationship with this one, and 2 MiB already
|
||||
// bounds the disk that syncAvatars can consume to a few tens of megabytes
|
||||
// across every listed code. The one caller that legitimately needs more is
|
||||
// self-update fetching a peer's source zip, and it passes its own value.
|
||||
export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// The unauthenticated probe reads only until it recognises an HTTP status line,
|
||||
// so it needs a far smaller ceiling than a full response: this bounds how long
|
||||
// it will listen to something that is not speaking HTTP at all.
|
||||
export const MAX_STATUS_LINE_BYTES = 64 * 1024;
|
||||
|
||||
// Send one HTTP/1.0 request over a fresh Tor stream and read the whole reply
|
||||
// (Connection: close means the server ends the body by closing). Resolves with
|
||||
// { status, body } or rejects on connect failure, read timeout, or a reply that
|
||||
// runs past maxBytes.
|
||||
export function httpOverTor(cfg, host, port, rawRequest, timeoutMs, maxBytes = MAX_RESPONSE_BYTES) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let socket;
|
||||
try {
|
||||
socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, timeoutMs);
|
||||
} catch (e) { return reject(e); }
|
||||
let buf = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(v); };
|
||||
const timer = setTimeout(() => done(reject, new Error("read-timeout")), timeoutMs);
|
||||
socket.on("data", (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
// Rejected the moment the ceiling is crossed rather than at close, so the
|
||||
// socket is destroyed and the memory released now. Waiting would mean a
|
||||
// node that never closes still occupies the full timeout while holding
|
||||
// everything it has sent. done() destroys the socket, so no further data
|
||||
// events arrive and the partial buffer goes out of scope with this call.
|
||||
if (buf.length > maxBytes) {
|
||||
done(reject, new Error(`response exceeded ${maxBytes} bytes`));
|
||||
}
|
||||
});
|
||||
socket.on("error", (e) => done(reject, e));
|
||||
socket.on("close", () => {
|
||||
const s = buf.toString("latin1");
|
||||
const m = s.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
const i = s.indexOf("\r\n\r\n");
|
||||
done(resolve, {
|
||||
status: m ? +m[1] : 0,
|
||||
body: i >= 0 ? s.slice(i + 4) : "",
|
||||
rawHead: i >= 0 ? s.slice(0, i + 2) : s, // headers incl. trailing CRLF
|
||||
bodyBuf: i >= 0 ? buf.subarray(i + 4) : Buffer.alloc(0), // exact bytes for binary payloads
|
||||
});
|
||||
});
|
||||
socket.write(rawRequest);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Dojo version from response headers -------------------------------------
|
||||
// The Dojo API sets its running version on every response (X-Dojo-Version). We
|
||||
// read it opportunistically while probing so the card can show the live value.
|
||||
// A node is only semi-trusted, so the value is validated and length-capped
|
||||
// before it can reach a data file: a version looks like 1, 1.28, 1.28.0 or
|
||||
// 1.28.0-rc1, with an optional leading v that we strip. Anything else -> null.
|
||||
export function normaliseVersion(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const v = raw.trim().replace(/^v/i, "").trim();
|
||||
if (!v || v.length > 32) return null;
|
||||
return /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/.test(v) ? v : null;
|
||||
}
|
||||
|
||||
// Pull the version out of a raw header block (the CRLF-joined header lines from
|
||||
// httpOverTor's rawHead, or the accumulated first bytes of a plain probe).
|
||||
// Header names are case-insensitive; the first occurrence wins.
|
||||
export function parseDojoVersion(rawHead, headerName = CFG.dojoVersionHeader) {
|
||||
if (typeof rawHead !== "string" || !rawHead) return null;
|
||||
const name = String(headerName).toLowerCase();
|
||||
for (const line of rawHead.split(/\r?\n/)) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx < 0) continue;
|
||||
if (line.slice(0, idx).trim().toLowerCase() !== name) continue;
|
||||
return normaliseVersion(line.slice(idx + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Electrum (indexer) endpoint from /support/services ---------------------
|
||||
// Dojo v1.27.0 added GET /support/services (ordinary apikey auth, not admin),
|
||||
// which returns { services: [ { type, kind, url }, … ] }. The "indexer" entry
|
||||
// is the node's Electrum server, published by the Dojo as
|
||||
// "<tcp|ssl>://<onion>:<port>" and present only when the operator exposes a
|
||||
// local indexer. Older Dojos have no such route, so absence is normal and is
|
||||
// reported as "not found" rather than an error.
|
||||
export function parseIndexerUrl(body) {
|
||||
let doc;
|
||||
try { doc = JSON.parse(body); } catch { return null; }
|
||||
const list = Array.isArray(doc?.services) ? doc.services : null;
|
||||
if (!list) return null;
|
||||
const hit = list.find((s) => s && s.type === "indexer" && typeof s.url === "string");
|
||||
return hit ? normaliseIndexerUrl(hit.url) : null;
|
||||
}
|
||||
|
||||
// A listed node is only semi-trusted, so the URL is validated and length-capped
|
||||
// before it can reach a data file or be rendered as a copyable string. Same
|
||||
// shape the card already accepts: tcp/ssl, v3 onion, explicit port.
|
||||
export function normaliseIndexerUrl(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const u = raw.trim();
|
||||
if (!u || u.length > 120) return null;
|
||||
return /^(tcp|ssl):\/\/[a-z2-7]{56}\.onion:\d{2,5}$/i.test(u) ? u : null;
|
||||
}
|
||||
|
||||
// ---- PayNym avatars ---------------------------------------------------------
|
||||
// Cards embed each node's PayNym avatar in the centre of its pairing QR. The
|
||||
// front end never fetches from third parties, so the avatar is mirrored here:
|
||||
// downloaded over Tor from the paynym.rs onion and served locally from
|
||||
// data/avatars/<paymentCode>.png. Missing files are fetched every cycle (which
|
||||
// also covers newly approved nodes within ten minutes) and existing ones are
|
||||
// refreshed weekly. Only verified PNG bytes are written; anything else -- an
|
||||
// error page, a redirect chain, an empty body -- is skipped without touching
|
||||
// the file, and failures are logged, never fatal.
|
||||
const PAYNYM_ONION = process.env.PAYNYM_ONION_HOST || "paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion";
|
||||
const AVATAR_MAX_AGE_MS = 7 * 86400000;
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
/**
|
||||
* @param {string} paymentCode
|
||||
* @param {{ proxyHost?: string, proxyPort?: number, destDir?: string,
|
||||
* timeoutMs?: number, host?: string, port?: number }} [opts]
|
||||
*/
|
||||
export async function fetchAvatar(paymentCode, { proxyHost, proxyPort, destDir, timeoutMs = 25000, host = PAYNYM_ONION, port = 80 } = {}) {
|
||||
const cfg = { proxyHost, proxyPort };
|
||||
let pathPart = `/${encodeURIComponent(paymentCode)}/avatar`;
|
||||
for (let hop = 0; hop < 2; hop++) { // follow at most one same-host redirect
|
||||
const req = `GET ${pathPart} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, host, port, req, timeoutMs);
|
||||
if ([301, 302, 307, 308].includes(res.status)) {
|
||||
const m = res.rawHead && res.rawHead.match(/\r\nlocation:\s*([^\r\n]+)/i);
|
||||
if (!m) throw new Error("redirect without location");
|
||||
const loc = m[1].trim();
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
const u = new URL(loc);
|
||||
if (u.hostname !== host) throw new Error("cross-host redirect");
|
||||
pathPart = u.pathname + u.search;
|
||||
} else pathPart = loc;
|
||||
continue;
|
||||
}
|
||||
if (res.status !== 200) throw new Error(`HTTP ${res.status || "no-response"}`);
|
||||
const bytes = res.bodyBuf || Buffer.from(res.body, "latin1");
|
||||
if (bytes.length < 8 || !bytes.subarray(0, 4).equals(PNG_MAGIC)) throw new Error("not a PNG");
|
||||
await fsMkdir(destDir, { recursive: true });
|
||||
const dest = path.join(destDir, `${paymentCode}.png`);
|
||||
const atmp = tmpName(dest);
|
||||
await writeFile(atmp, bytes);
|
||||
await rename(atmp, dest);
|
||||
return dest;
|
||||
}
|
||||
throw new Error("too many redirects");
|
||||
}
|
||||
|
||||
// Ensure a local avatar exists (and is reasonably fresh) for every listed
|
||||
// payment code. Small concurrency; per-code failures are logged and skipped.
|
||||
async function syncAvatars(nodes, destDir) {
|
||||
const codes = [...new Set(nodes.map((n) => n.paymentCode).filter(Boolean))];
|
||||
const wanted = [];
|
||||
for (const code of codes) {
|
||||
try {
|
||||
const st = await fsStat(path.join(destDir, `${code}.png`));
|
||||
if (Date.now() - st.mtimeMs < AVATAR_MAX_AGE_MS) continue;
|
||||
} catch { /* missing -> fetch */ }
|
||||
wanted.push(code);
|
||||
}
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const code = wanted[i++];
|
||||
if (!code) return;
|
||||
try {
|
||||
await fetchAvatar(code, { proxyHost: CFG.proxyHost, proxyPort: CFG.proxyPort, destDir });
|
||||
console.error(`[avatar] fetched ${code.slice(0, 12)}…`);
|
||||
} catch (e) {
|
||||
console.error(`[avatar] ${code.slice(0, 12)}…: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(3, wanted.length) }, worker));
|
||||
}
|
||||
|
||||
// Authenticated health check: log in with the node's apikey, then read the
|
||||
// chain tip from GET /v2/wallet. The Dojo stamps X-Dojo-Version on every
|
||||
// response, so we harvest it from the first response that carries it (the login
|
||||
// reply always does) even on an otherwise-down cycle. Returns
|
||||
// { up, reason, ms, height?, blockTime?, detectedVersion? }.
|
||||
async function probeHeight(url, cfg) {
|
||||
const t0 = Date.now();
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
const port = u.port ? +u.port : 80;
|
||||
const base = (u.pathname || "/v2").replace(/\/+$/, "") || "/v2"; // e.g. /v2
|
||||
const dummy = cfg.network === "testnet" ? DUMMY_TPUB : DUMMY_XPUB;
|
||||
let detectedVersion = null;
|
||||
|
||||
// 1) login -> access token
|
||||
let token;
|
||||
try {
|
||||
const body = `apikey=${encodeURIComponent(cfg.apikey)}`;
|
||||
const req =
|
||||
`POST ${base}/auth/login HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Content-Type: application/x-www-form-urlencoded\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` +
|
||||
`User-Agent: dojobay-checker\r\nConnection: close\r\n\r\n${body}`;
|
||||
const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs);
|
||||
detectedVersion = parseDojoVersion(res.rawHead, cfg.dojoVersionHeader) || detectedVersion;
|
||||
if (res.status !== 200) return { up: false, reason: `login HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion };
|
||||
token = JSON.parse(res.body)?.authorizations?.access_token;
|
||||
if (!token) return { up: false, reason: "login: no token", ms: Date.now() - t0, detectedVersion };
|
||||
} catch (e) {
|
||||
return { up: false, reason: "login: " + e.message, ms: Date.now() - t0, detectedVersion };
|
||||
}
|
||||
|
||||
// 2) wallet -> info.latest_block.height
|
||||
try {
|
||||
const q = `active=${dummy}&new=${dummy}`;
|
||||
const req =
|
||||
`GET ${base}/wallet?${q} HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs);
|
||||
detectedVersion = detectedVersion || parseDojoVersion(res.rawHead, cfg.dojoVersionHeader);
|
||||
if (res.status !== 200) return { up: false, reason: `wallet HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion };
|
||||
const info = JSON.parse(res.body)?.info?.latest_block;
|
||||
const height = info?.height;
|
||||
if (typeof height !== "number") return { up: false, reason: "wallet: no block height", ms: Date.now() - t0, detectedVersion };
|
||||
|
||||
// 3) services -> Electrum (indexer) endpoint. Best-effort and strictly
|
||||
// additive: the node is already known up, so a missing route (pre-1.27.0),
|
||||
// a node that exposes no indexer, or any error here must never downgrade
|
||||
// the result. Absence simply means the card shows N/A.
|
||||
let detectedIndexer = null;
|
||||
try {
|
||||
const sreq =
|
||||
`GET ${base}/support/services HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const sres = await httpOverTor(cfg, host, port, sreq, cfg.timeoutMs);
|
||||
detectedVersion = detectedVersion || parseDojoVersion(sres.rawHead, cfg.dojoVersionHeader);
|
||||
if (sres.status === 200) detectedIndexer = parseIndexerUrl(sres.body);
|
||||
} catch { /* leave null */ }
|
||||
|
||||
return { up: true, reason: "height", height, blockTime: info.time ?? null, ms: Date.now() - t0, detectedVersion, detectedIndexer };
|
||||
} catch (e) {
|
||||
return { up: false, reason: "wallet: " + e.message, ms: Date.now() - t0, detectedVersion };
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Probe a single onion URL. Returns { up, reason, ms }.
|
||||
// up = Tor connected AND (CONNECT_ONLY, or an HTTP status line came back)
|
||||
// -----------------------------------------------------------------------------
|
||||
// Fill in the transport settings a probe cannot work without. Callers pass a
|
||||
// partial config (an apikey and a network, say) and it is easy to forget to
|
||||
// spread PROBE_CFG or CFG alongside it; without these, net.connect is handed an
|
||||
// undefined port and Node reports 'The "options" or "port" or "path" argument
|
||||
// must be specified', which says nothing about the real mistake. The defaults
|
||||
// are the same ones PROBE_CFG uses, so a partial config now behaves rather than
|
||||
// failing obscurely. Explicitly supplied values always win.
|
||||
/**
|
||||
* @param {Partial<import("../types.js").ProbeCfg>} [cfg]
|
||||
* @returns {import("../types.js").ProbeCfg}
|
||||
*/
|
||||
export function probeCfg(cfg = {}) {
|
||||
return {
|
||||
...cfg,
|
||||
proxyHost: cfg.proxyHost ?? (process.env.TOR_SOCKS_HOST || "127.0.0.1"),
|
||||
proxyPort: cfg.proxyPort ?? +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
// Same default as CFG below, from one place. These were separate literals
|
||||
// and had already diverged: the cron path waited 45 seconds while anything
|
||||
// going through this helper waited 30, so the same node could be up for one
|
||||
// caller and down for the other.
|
||||
timeoutMs: cfg.timeoutMs ?? +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||
concurrency: cfg.concurrency ?? +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {Partial<import("../types.js").ProbeCfg>} [cfgIn]
|
||||
* @returns {Promise<import("../types.js").ProbeResult>}
|
||||
*/
|
||||
export async function probe(url, cfgIn = CFG) {
|
||||
const cfg = probeCfg(cfgIn);
|
||||
// Preferred path: authenticated chain-tip check when an apikey is available.
|
||||
if (cfg.apikey) return probeHeight(url, cfg);
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
const port = u.port ? +u.port : (u.protocol === "https:" ? 443 : 80);
|
||||
const reqPath = (u.pathname || "/") + (u.search || "");
|
||||
const t0 = Date.now();
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, cfg.timeoutMs);
|
||||
} catch (e) {
|
||||
return { up: false, reason: e.message, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
// TLS onions or connect-only mode: a successful Tor stream is the signal.
|
||||
if (cfg.connectOnly || u.protocol === "https:") {
|
||||
socket.destroy();
|
||||
return { up: true, reason: u.protocol === "https:" ? "tls-connect" : "connect", ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
// Otherwise confirm the Dojo HTTP server actually answers.
|
||||
return await new Promise((resolve) => {
|
||||
let got = "";
|
||||
let settled = false;
|
||||
const finish = (up, reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
// A code-less node has no apikey, so this is the only chance to read its
|
||||
// version; the header rides in the same first packet as the status line
|
||||
// often enough to be worth a look. Absent -> null, harmless.
|
||||
resolve({ up, reason, ms: Date.now() - t0, detectedVersion: parseDojoVersion(got, cfg.dojoVersionHeader) });
|
||||
};
|
||||
const timer = setTimeout(() => finish(got.length > 0, got ? "partial" : "read-timeout"), cfg.timeoutMs);
|
||||
|
||||
socket.on("data", (d) => {
|
||||
got += d.toString("latin1");
|
||||
if (/^HTTP\//i.test(got)) finish(true, "http");
|
||||
// The same unbounded accumulation httpOverTor had, reached by a different
|
||||
// door. A well-behaved server puts its status line in the first packet
|
||||
// and the test above ends the read immediately, but a node that sends
|
||||
// anything NOT starting with "HTTP/" is never matched, so before this
|
||||
// guard `got` grew until the timeout with no ceiling at all. A status
|
||||
// line is a few dozen bytes; 64 KiB without one means this is not an HTTP
|
||||
// server, which is the answer the probe wanted anyway.
|
||||
else if (got.length > MAX_STATUS_LINE_BYTES) finish(false, "no-http-response");
|
||||
});
|
||||
socket.on("error", () => finish(got.length > 0, "socket-error"));
|
||||
socket.on("close", () => finish(got.length > 0, "closed"));
|
||||
|
||||
socket.write(
|
||||
`HEAD ${reqPath} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- date helpers (UTC, matching the formats already in the JSON) -----------
|
||||
const p2 = (n) => String(n).padStart(2, "0");
|
||||
function stamps(d = new Date()) {
|
||||
const Y = d.getUTCFullYear(), M = p2(d.getUTCMonth() + 1), D = p2(d.getUTCDate());
|
||||
const h = p2(d.getUTCHours()), m = p2(d.getUTCMinutes()), s = p2(d.getUTCSeconds());
|
||||
return {
|
||||
isoSec: `${Y}-${M}-${D}T${h}:${m}:${s}Z`, // generated_at
|
||||
isoMin: `${Y}-${M}-${D}T${h}:${m}Z`, // history check timestamp
|
||||
dateTime: `${Y}-${M}-${D} ${h}:${m}:${s}`, // node.checked_at
|
||||
};
|
||||
}
|
||||
|
||||
// ---- small concurrency pool -------------------------------------------------
|
||||
async function pool(items, limit, fn) {
|
||||
const out = new Array(items.length);
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
out[idx] = await fn(items[idx], idx);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJSON(file, fallback) {
|
||||
try { return JSON.parse(await readFile(file, "utf8")); }
|
||||
catch (e) { if (e.code === "ENOENT" && fallback !== undefined) return fallback; throw e; }
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take.
|
||||
//
|
||||
// Every atomic write here was `<file>.tmp`, which is not atomic between
|
||||
// processes: two writers produce the same path, the first rename consumes it,
|
||||
// and the second fails with ENOENT on a file it had just written. That is not
|
||||
// hypothetical. The installer enables the update timer and then runs its own
|
||||
// first probe cycle, and once the timer gained a calendar schedule with
|
||||
// Persistent=true, enabling it fired a catch-up run immediately rather than
|
||||
// after two minutes. Two updaters wrote data/dojos.json.tmp at once and the
|
||||
// install ended by announcing failures on a directory that was already
|
||||
// updating.
|
||||
//
|
||||
// The pid and a counter are enough: the collision is between processes on one
|
||||
// machine, and the rename is what makes the swap atomic for readers.
|
||||
function tmpName(file) {
|
||||
return `${file}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
}
|
||||
let tmpSeq = 0;
|
||||
|
||||
// Write atomically: a reader (the website) never sees a half-written file.
|
||||
async function writeJSONAtomic(file, obj) {
|
||||
const tmp = tmpName(file);
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, file);
|
||||
}
|
||||
|
||||
// Merge seed + approved submissions into the public list (delegates to
|
||||
// server/build-public.mjs, which preserves live statuses and histories).
|
||||
// Exported so the self-test can drive it against isolated data directories.
|
||||
export async function reconcilePublicList() {
|
||||
if (!process.env.PUBLIC_DATA_DIR) process.env.PUBLIC_DATA_DIR = CFG.dataDir;
|
||||
const { rebuild } = await import("../server/build-public.ts");
|
||||
return rebuild();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
async function main() {
|
||||
const dojosPath = path.join(CFG.dataDir, "dojos.json");
|
||||
// Reconcile FIRST: fold the curated seed and every APPROVED submission into
|
||||
// dojos.json before this cycle reads it. The admin approve does its own
|
||||
// rebuild, but that write is lost if it lands while a probe cycle (minutes
|
||||
// long over Tor) is in flight, because the cycle writes back the node list
|
||||
// it read at the start. Rebuilding here means an approved node can be absent
|
||||
// for at most one cycle, never indefinitely.
|
||||
try {
|
||||
const r = await reconcilePublicList();
|
||||
console.error(`[reconcile] ${r.msg}`);
|
||||
} catch (e) {
|
||||
console.error(`[reconcile] skipped: ${e.message}`);
|
||||
}
|
||||
const historyPath = path.join(CFG.dataDir, "history.json");
|
||||
|
||||
const dojos = await readJSON(dojosPath);
|
||||
if (!dojos || !Array.isArray(dojos.nodes)) throw new Error(`bad or missing ${dojosPath}`);
|
||||
// Keep the self-hosted source download current: regenerate the zip when it
|
||||
// is missing or older than data/version.json (i.e. after any code deploy).
|
||||
try {
|
||||
const zipPath = path.join(CFG.dataDir, "dojobay-src.zip");
|
||||
const verPath = path.join(CFG.dataDir, "version.json");
|
||||
const zipSt = await fsStat(zipPath).catch(() => null);
|
||||
const verSt = await fsStat(verPath).catch(() => null);
|
||||
if (!zipSt || (verSt && verSt.mtimeMs > zipSt.mtimeMs)) {
|
||||
const { packSource } = await import("./pack-source.mjs");
|
||||
const r = await packSource({ outDir: CFG.dataDir });
|
||||
console.error(`[src-zip] repacked: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`);
|
||||
}
|
||||
} catch (e) { console.error(`[src-zip] skipped: ${e.message}`); }
|
||||
|
||||
// Mirror PayNym avatars for every listed code (non-blocking for the probes).
|
||||
const operatorDoc = await readJSON(path.join(CFG.dataDir, "operator.json")).catch(() => null) ?? {};
|
||||
const avatarSubjects = dojos.nodes.concat(operatorDoc.paymentCode ? [{ paymentCode: operatorDoc.paymentCode }] : []);
|
||||
const avatarsDone = syncAvatars(avatarSubjects, path.join(CFG.dataDir, "avatars")).catch((e) => console.error("[avatar]", e.message));
|
||||
const history = await readJSON(historyPath, { interval_minutes: 10, window_checks: CFG.windowChecks, nodes: {} });
|
||||
const window = history.window_checks || CFG.windowChecks;
|
||||
|
||||
const now = new Date();
|
||||
const ts = stamps(now);
|
||||
console.error(`[${ts.isoSec}] probing ${dojos.nodes.length} nodes via socks5h://${CFG.proxyHost}:${CFG.proxyPort} (timeout ${CFG.timeoutMs}ms, concurrency ${CFG.concurrency})`);
|
||||
|
||||
const results = await pool(dojos.nodes, CFG.concurrency, async (n) => {
|
||||
const url = n?.payload?.pairing?.url;
|
||||
if (!url) return { up: false, reason: "no pairing url", ms: 0 };
|
||||
return probe(url, { ...CFG, apikey: n?.payload?.pairing?.apikey, network: n.network });
|
||||
});
|
||||
|
||||
// ---- did this cycle learn anything? ----
|
||||
//
|
||||
// Fifteen independently operated nodes on different continents do not fail in
|
||||
// the same ten-minute window. When every one of them fails, the cause is here:
|
||||
// Tor rebuilding circuits after a suspend, a home connection renegotiating,
|
||||
// the daemon restarted underneath us. Recording that would write a DOWN check
|
||||
// against every operator in the directory and pull down reliability figures
|
||||
// this instance publishes about other people's machines, for a fault of its
|
||||
// own. So it is not recorded.
|
||||
//
|
||||
// The threshold is zero rather than a proportion. A cycle where some nodes
|
||||
// answer proves the local path works, and the ones that did not answer really
|
||||
// did not; only a clean sweep is evidence about this machine instead of about
|
||||
// them. A directory with one listing would trip this on a genuine outage, and
|
||||
// that is the right trade: withholding one node's bad cycle costs far less
|
||||
// than publishing a false one against everybody.
|
||||
const allFailed = dojos.nodes.length > 0 && results.every((r) => !r.up);
|
||||
|
||||
// ---- update current snapshot ----
|
||||
let up = 0;
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const r = results[i];
|
||||
if (r.up) up++;
|
||||
n.status = r.up ? "active" : "inactive";
|
||||
n.checked_at = ts.dateTime;
|
||||
// Record the tip height when we read one; keep the last known height on a
|
||||
// down cycle so the card can still show where the node last was.
|
||||
if (typeof r.height === "number") n.block_height = r.height;
|
||||
else if (!("block_height" in n)) n.block_height = null;
|
||||
// Same sticky rule for the version read from X-Dojo-Version: update it when
|
||||
// this cycle saw one, otherwise leave the last known value in place. The
|
||||
// effective card version (operator override > detected > pairing default)
|
||||
// is computed by build-public.mjs, which carries this field across the
|
||||
// reconcile rebuild that opens every cycle.
|
||||
if (r.detectedVersion) n.detected_version = r.detectedVersion;
|
||||
else if (!("detected_version" in n)) n.detected_version = null;
|
||||
// Same sticky rule for the Electrum endpoint read from /support/services:
|
||||
// keep the last known value when a cycle didn't read one, so a node that is
|
||||
// merely down for a cycle doesn't flip its card to N/A. build-public.mjs
|
||||
// computes the published value and carries this field across the rebuild.
|
||||
if (r.detectedIndexer) n.detected_indexer = r.detectedIndexer;
|
||||
else if (!("detected_indexer" in n)) n.detected_indexer = null;
|
||||
});
|
||||
dojos.interval_minutes = dojos.interval_minutes || 10;
|
||||
|
||||
if (allFailed) {
|
||||
// Publish the fault and nothing else. Statuses, heights and checked_at stay
|
||||
// as the last cycle that actually reached something left them, and
|
||||
// generated_at is deliberately not advanced, so the staleness banner keeps
|
||||
// measuring the age of real data rather than the age of a failure.
|
||||
const fresh = await readJSON(dojosPath, null);
|
||||
if (fresh) {
|
||||
fresh.probe_fault = { at: ts.isoSec, nodes: dojos.nodes.length };
|
||||
await writeJSONAtomic(dojosPath, fresh);
|
||||
}
|
||||
console.error(`[${ts.isoSec}] every one of ${dojos.nodes.length} nodes failed, which is`
|
||||
+ " almost certainly a fault here rather than all of them at once.");
|
||||
console.error(" Nothing was recorded: no statuses changed and no history written.");
|
||||
console.error(" Check Tor on this machine (systemctl status tor@default), and the clock.");
|
||||
return;
|
||||
}
|
||||
dojos.generated_at = ts.isoSec;
|
||||
delete dojos.probe_fault;
|
||||
|
||||
// ---- update rolling history (append + trim, retire stale ids) ----
|
||||
const listed = new Set(dojos.nodes.map((n) => n.id));
|
||||
const histNodes = {};
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const prev = (history.nodes?.[n.id]?.checks) || [];
|
||||
const checks = prev.concat([{ t: ts.isoMin, up: results[i].up }]);
|
||||
if (checks.length > window) checks.splice(0, checks.length - window);
|
||||
histNodes[n.id] = { checks };
|
||||
});
|
||||
// Unlisted ids are kept under a `retired` stamp for HISTORY_GRACE_DAYS (same
|
||||
// rule as build-public.mjs), so a bad or transient node list cannot destroy
|
||||
// accumulated history; a resurrected id resumes where it left off.
|
||||
for (const id of Object.keys(history.nodes || {})) if (!histNodes[id]) histNodes[id] = history.nodes[id];
|
||||
retireUnlisted(histNodes, (id) => listed.has(id), ts.isoSec);
|
||||
|
||||
await writeJSONAtomic(dojosPath, dojos);
|
||||
await writeJSONAtomic(historyPath, {
|
||||
generated_at: ts.isoSec,
|
||||
interval_minutes: history.interval_minutes || 10,
|
||||
window_checks: window,
|
||||
nodes: histNodes,
|
||||
});
|
||||
|
||||
// ---- update 90-day daily rollup (per-day uptime + closing block height) ----
|
||||
// One record per node per UTC day; `close` is the last height read that day,
|
||||
// so at day's end it holds the closing height. Retained RETENTION_DAYS days.
|
||||
const dailyPath = path.join(CFG.dataDir, "history-daily.json");
|
||||
const daily = await readJSON(dailyPath, { retention_days: CFG.retentionDays, nodes: {} });
|
||||
const today = ts.dateTime.slice(0, 10); // YYYY-MM-DD (UTC)
|
||||
const dailyNodes = {};
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const r = results[i];
|
||||
const days = ((daily.nodes?.[n.id]?.days) || []).map((d) => ({ ...d }));
|
||||
let rec = days.length && days[days.length - 1].d === today ? days[days.length - 1] : null;
|
||||
if (!rec) { rec = { d: today, up: 0, total: 0, pct: 0, close: null }; days.push(rec); }
|
||||
rec.total += 1;
|
||||
if (r.up) rec.up += 1;
|
||||
rec.pct = Math.round((rec.up / rec.total) * 1000) / 10;
|
||||
if (typeof r.height === "number") rec.close = r.height;
|
||||
if (days.length > CFG.retentionDays) days.splice(0, days.length - CFG.retentionDays);
|
||||
dailyNodes[n.id] = { days };
|
||||
});
|
||||
for (const id of Object.keys(daily.nodes || {})) if (!dailyNodes[id]) dailyNodes[id] = daily.nodes[id];
|
||||
retireUnlisted(dailyNodes, (id) => listed.has(id), ts.isoSec);
|
||||
await writeJSONAtomic(dailyPath, {
|
||||
generated_at: ts.isoSec,
|
||||
retention_days: CFG.retentionDays,
|
||||
nodes: dailyNodes,
|
||||
});
|
||||
|
||||
// ---- probe PENDING submissions so the operator sees uptime before approving
|
||||
// Results are written server-side only (server/data/pending-probe.json), never
|
||||
// to the public data/, so an unapproved submission is not exposed over Tor.
|
||||
try {
|
||||
const { store } = await import("../server/store.ts");
|
||||
const serverDataDir = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "server", "data");
|
||||
const pendingPath = path.join(serverDataDir, "pending-probe.json");
|
||||
const subs = (await store.listSubmissions()).filter((s) => s.status === "pending");
|
||||
if (subs.length) {
|
||||
const prevDoc = await readJSON(pendingPath, { window_checks: window, nodes: {} });
|
||||
const presults = await pool(subs, CFG.concurrency, async (s) => {
|
||||
const url = s?.payload?.pairing?.url;
|
||||
if (!url) return { up: false, reason: "no pairing url", ms: 0 };
|
||||
return probe(url, { ...CFG, apikey: s?.payload?.pairing?.apikey, network: s.network });
|
||||
});
|
||||
const pnodes = {};
|
||||
subs.forEach((s, i) => {
|
||||
const r = presults[i];
|
||||
const prev = (prevDoc.nodes?.[s.id]?.checks) || [];
|
||||
const checks = prev.concat([{ t: ts.isoMin, up: r.up }]);
|
||||
if (checks.length > window) checks.splice(0, checks.length - window);
|
||||
pnodes[s.id] = {
|
||||
status: r.up ? "active" : "inactive",
|
||||
checked_at: ts.dateTime,
|
||||
block_height: typeof r.height === "number" ? r.height
|
||||
: (prevDoc.nodes?.[s.id]?.block_height ?? null),
|
||||
detected_version: r.detectedVersion || (prevDoc.nodes?.[s.id]?.detected_version ?? null),
|
||||
detected_indexer: r.detectedIndexer || (prevDoc.nodes?.[s.id]?.detected_indexer ?? null),
|
||||
checks,
|
||||
};
|
||||
});
|
||||
await writeJSONAtomic(pendingPath, { generated_at: ts.isoSec, window_checks: window, nodes: pnodes });
|
||||
console.error(`[${ts.isoSec}] probed ${subs.length} pending submission(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${ts.isoSec}] pending probe skipped: ${e.message}`);
|
||||
}
|
||||
|
||||
console.error(`[${ts.isoSec}] done: ${up}/${dojos.nodes.length} active`);
|
||||
for (const [i, n] of dojos.nodes.entries()) {
|
||||
const r = results[i];
|
||||
console.error(` ${r.up ? "UP " : "DOWN"} ${n.id.padEnd(28)} ${String(r.ms).padStart(6)}ms ${r.reason || ""}`);
|
||||
}
|
||||
await avatarsDone; // let in-flight avatar mirrors finish before the timer unit exits
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main().catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Maintainer moderation CLI (run on the server by a maintainer over SSH).
|
||||
// node admin.mjs list show pending/approved/rejected
|
||||
// node admin.mjs approve <id> [paynym] approve a submission (optionally set its PayNym)
|
||||
// node admin.mjs reject <id> reject a submission
|
||||
// node admin.mjs remove <id> delete a submission outright
|
||||
// After approving/rejecting, run build-public.mjs to regenerate the public list.
|
||||
import { store } from "./store.ts";
|
||||
import { resolvePayNym } from "./paynym.mjs";
|
||||
|
||||
const [cmd, id, extra] = process.argv.slice(2);
|
||||
|
||||
function line(r) {
|
||||
return `${r.status.padEnd(8)} ${r.id.padEnd(26)} ${r.network.padEnd(7)} ${(r.paynym || "-").padEnd(18)} ${(r.name || "-").padEnd(18)} ${r.payload?.pairing?.url || ""}`;
|
||||
}
|
||||
|
||||
const cmds = {
|
||||
async list() {
|
||||
const subs = await store.listSubmissions();
|
||||
if (!subs.length) return console.log("(no submissions)");
|
||||
for (const r of subs.sort((a, b) => (a.status).localeCompare(b.status))) console.log(line(r));
|
||||
},
|
||||
async approve() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "approved";
|
||||
if (extra) {
|
||||
r.paynym = extra.startsWith("+") ? extra : "+" + extra; // maintainer override
|
||||
} else if (!r.paynym) {
|
||||
const resolved = await resolvePayNym((r.paymentCodes || [])[0]).catch(() => null);
|
||||
if (resolved) r.paynym = resolved;
|
||||
}
|
||||
r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("approved:", id, "paynym:", r.paynym || "(none set — pass one as the 3rd arg)");
|
||||
console.log("now run: node build-public.mjs");
|
||||
},
|
||||
async reject() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "rejected"; r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("rejected:", id, "(run build-public.mjs to drop it from the public list)");
|
||||
},
|
||||
async remove() {
|
||||
await store.deleteSubmission(id);
|
||||
console.log("removed:", id);
|
||||
},
|
||||
};
|
||||
|
||||
(cmds[cmd] || (async () => { console.log("usage: node admin.mjs [list|approve <id> [paynym]|reject <id>|remove <id>]"); }))()
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => { console.error("error:", e.message); process.exit(1); });
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — apply operator-signed pairing payload updates.
|
||||
//
|
||||
// Takes signed blocks an operator has sent out of band (a re-signed pairing
|
||||
// payload, a new apikey, a moved onion) and applies them to the store, doing
|
||||
// exactly what the submission gate would have done had they gone through the
|
||||
// site:
|
||||
//
|
||||
// 1. the block must parse, and its signature must be valid over its own text;
|
||||
// 2. the BIP47 code inside the signed text must derive the signing address;
|
||||
// 3. that code must already own a record here, which is how the update is
|
||||
// matched to a listing;
|
||||
// 4. the payload written is the one INSIDE the signed block, so what is
|
||||
// published is exactly what the operator attested to.
|
||||
//
|
||||
// The record's id is never changed, so its reliability history survives. Status
|
||||
// is left alone: an approved listing stays approved, a pending one stays pending.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node apply-signed-payload.ts blocks/*.txt # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node apply-signed-payload.ts --apply blocks/*.txt
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node audit-signed.mjs
|
||||
//
|
||||
// Each file holds one signed block. `--id <record-id>` pins the target when a
|
||||
// payment code owns more than one listing. As with fix-payload-version, --apply
|
||||
// refuses to run while the service is up, because store.ts holds the store in
|
||||
// memory as a single writer and would overwrite the edit.
|
||||
// =============================================================================
|
||||
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// canonicalPairing is imported, never reimplemented: this tool must accept
|
||||
// exactly what the submission gate accepts, and a second definition of the
|
||||
// canonical message would diverge silently. server/selftest.mjs enforces it.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const idFlag = argv.indexOf("--id");
|
||||
const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null;
|
||||
const FILES = argv.filter((a, i) =>
|
||||
!a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1));
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
if (!FILES.length) {
|
||||
console.error("Usage: node apply-signed-payload.ts [--apply] [--id <record-id>] <file>…\n" +
|
||||
"Each file contains one BEGIN BITCOIN SIGNED MESSAGE block.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
|
||||
catch (e: any) { active = (e.stdout || "").trim(); }
|
||||
if (active === "active") {
|
||||
console.error("REFUSING: dojobay-server.service is running.\n" +
|
||||
"The store is held in memory by the server and would overwrite this edit.\n" +
|
||||
" sudo systemctl stop dojobay-server.service\n" +
|
||||
" node apply-signed-payload.ts --apply <files…>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = JSON.parse(await readFile(FILE, "utf8"));
|
||||
const records: StoreRecord[] = Object.values(doc.submissions || {});
|
||||
|
||||
interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null }
|
||||
const planned: Planned[] = [];
|
||||
const refused: [string, string][] = [];
|
||||
|
||||
for (const file of FILES) {
|
||||
let signed: string;
|
||||
try { signed = await readFile(file, "utf8"); }
|
||||
catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; }
|
||||
|
||||
// Copying a block through chat, a form or a mail client routinely eats the
|
||||
// blank line before the BIP47 line, which the signature covers. Repair it if
|
||||
// a reconstruction verifies cryptographically; nothing is taken on trust.
|
||||
let note: string | null = null;
|
||||
const repaired = repairSignedBlock(signed);
|
||||
if (repaired) { signed = repaired.block; note = repaired.note; }
|
||||
|
||||
const parsed = parseSignedBlock(signed);
|
||||
if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; }
|
||||
if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; }
|
||||
|
||||
// The payload published is the one inside the signed block, never a
|
||||
// hand-copied version of it.
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(parsed.pairingText); }
|
||||
catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; }
|
||||
if (!payload?.pairing?.url || !payload?.pairing?.type) {
|
||||
refused.push([file, "the signed payload has no pairing.url/type"]); continue;
|
||||
}
|
||||
|
||||
const addrs = notificationAddresses(parsed.paymentCode);
|
||||
const v = verifySignedPayload({
|
||||
signedText: signed,
|
||||
expectedMessage: canonicalPairing(payload),
|
||||
expectedAddress: addrs,
|
||||
});
|
||||
if (!v.ok) { refused.push([file, v.error]); continue; }
|
||||
|
||||
const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!));
|
||||
const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined);
|
||||
if (!owned.length) {
|
||||
refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue;
|
||||
}
|
||||
if (!target) {
|
||||
refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue;
|
||||
}
|
||||
|
||||
planned.push({
|
||||
file, rec: target, payload, signed: signed.trim(), note,
|
||||
before: target.payload?.pairing?.url || "(none)",
|
||||
after: payload.pairing.url,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Blocks read: ${FILES.length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Will update (${planned.length}):`);
|
||||
for (const p of planned) {
|
||||
console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`);
|
||||
console.log(` url ${p.before}`);
|
||||
console.log(` -> ${p.after}`);
|
||||
const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version;
|
||||
if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`);
|
||||
if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)");
|
||||
if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
if (refused.length) {
|
||||
console.log(`Refused (${refused.length}):`);
|
||||
for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to make these changes.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = `${FILE}.bak-${stamp}`;
|
||||
await copyFile(FILE, backup);
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const p of planned) {
|
||||
const rec = doc.submissions[p.rec.id];
|
||||
rec.payload = p.payload; // exactly what was signed
|
||||
rec.signed = p.signed;
|
||||
rec.updated_at = nowIso;
|
||||
}
|
||||
// A temporary name no other writer can take; see build-public.ts. This tool
|
||||
// refuses to run while the service holds the store, so a collision needs two
|
||||
// maintenance tools at once, which is exactly the case nobody plans for.
|
||||
const tmp = `${FILE}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
|
||||
console.log(`Backup written: ${backup}`);
|
||||
console.log(`Applied ${planned.length} update(s).`);
|
||||
console.log("Start the service again, then run audit-signed.mjs; each updated record\n" +
|
||||
"should now read VERIFIED. The published dojos.json follows on the next\n" +
|
||||
"updater cycle, or immediately if you run build-public.mjs.");
|
||||
process.exit(refused.length ? 1 : 0);
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — audit stored signed pairing blocks.
|
||||
//
|
||||
// READ-ONLY. Walks every record in the submission store and re-checks its
|
||||
// stored `signed` block with exactly the gate the submit endpoint uses
|
||||
// (verifySignedPayload over canonicalPairing(payload), against the notification
|
||||
// address of the record's own payment code). Nothing is written, no network is
|
||||
// touched, and the store is only ever read.
|
||||
//
|
||||
// Why this exists: records approved before the signed-message parser was fixed
|
||||
// were checked by a parser that excised the BIP47 tail before verifying, so the
|
||||
// verdict they received then is not the verdict they would receive now. This
|
||||
// tells you whether anything was left behind.
|
||||
//
|
||||
// Run on the box as the deploy user:
|
||||
// cd /var/www/dojobay/server && node audit-signed.mjs
|
||||
// SERVER_DATA_DIR defaults to ./data, the same path the server uses; set it
|
||||
// only if your store lives elsewhere.
|
||||
//
|
||||
// Buckets:
|
||||
// VERIFIED the stored signature is valid for one of the record's codes
|
||||
// FAILED a signature is present but verifies for none of them
|
||||
// UNSIGNED no signature stored (pre-gate migration, or a code-less record)
|
||||
// ERROR the record could not be evaluated at all
|
||||
// Exits non-zero if anything is FAILED, ERROR or UNSIGNED, so it can back a
|
||||
// cron check. UNSIGNED counted as a failure since the signature became a
|
||||
// structural requirement: the store refuses to write such a record and the
|
||||
// rebuild withholds it, so one showing up here is not awaiting a decision.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
import { verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
|
||||
const networkOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Exported so the test suite can assert this reproduces the gate's verdict.
|
||||
// This MUST mirror server/index.mjs's signature gate exactly: same canonical
|
||||
// message, and the same set of acceptable signing addresses. An earlier version
|
||||
// derived the notification address for the record's own network, which meant
|
||||
// every testnet listing was reported as failing even though the gate accepted
|
||||
// it, because a PayNym signs from its mainnet address whatever the node is.
|
||||
export function auditRecord(rec) {
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
if (!rec.signed) {
|
||||
return { bucket: "UNSIGNED", detail: codes.length ? "record has a payment code but no signed block" : "no signed block and no payment code" };
|
||||
}
|
||||
const net = networkOf(rec);
|
||||
const expectedMessage = canonicalPairing(rec.payload);
|
||||
const tried = [];
|
||||
// A PayNym may have signed with either BIP47 variant, so every code on the
|
||||
// record is a legitimate candidate; the first that verifies wins.
|
||||
for (const code of codes) {
|
||||
const addrs = notificationAddresses(code);
|
||||
if (!addrs.length) { tried.push(`${code.slice(0, 12)}…: undecodable code`); continue; }
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, expectedAddress: addrs, network: net });
|
||||
if (r.ok) return { bucket: "VERIFIED", detail: `${code.slice(0, 12)}… → ${addrs[0]}` };
|
||||
tried.push(`${code.slice(0, 12)}… (${addrs.join(" / ")}): ${r.error}`);
|
||||
}
|
||||
if (!codes.length) {
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, network: net });
|
||||
return r.ok
|
||||
? { bucket: "FAILED", detail: "signature is internally valid but the record carries no payment code to bind it to" }
|
||||
: { bucket: "FAILED", detail: r.error };
|
||||
}
|
||||
return { bucket: "FAILED", detail: tried.join("\n ") };
|
||||
}
|
||||
|
||||
// ---- CLI ---------------------------------------------------------------
|
||||
// Only runs when executed directly, so tests can import auditRecord.
|
||||
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (!isMain) { /* imported for testing */ } else {
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const buckets = { VERIFIED: [], FAILED: [], UNSIGNED: [], ERROR: [] };
|
||||
for (const rec of recs) {
|
||||
let res;
|
||||
try { res = auditRecord(rec); } catch (e) { res = { bucket: "ERROR", detail: e.message }; }
|
||||
buckets[res.bucket].push({ rec, detail: res.detail });
|
||||
}
|
||||
|
||||
console.log(`Audited ${recs.length} record(s) in the store.\n`);
|
||||
for (const b of ["FAILED", "ERROR", "UNSIGNED", "VERIFIED"]) {
|
||||
if (!buckets[b].length) continue;
|
||||
console.log(`${b}: ${buckets[b].length}`);
|
||||
for (const { rec, detail } of buckets[b]) {
|
||||
// Show the name as well as the id. Ids are immutable (reliability history
|
||||
// keys on them), so a record created before operator naming keeps its
|
||||
// payment-code-derived id even after its operator sets a name, and the id
|
||||
// alone is then unrecognisable.
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(` [${b}] ${label} (${rec.status})${detail ? "\n " + detail : ""}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// An UNSIGNED record is now a failure, not a decision. Until the signature rule
|
||||
// existed there was a legitimate answer to "this record predates the gate" and
|
||||
// the audit deliberately left the judgement to a maintainer. The store now
|
||||
// refuses to write such a record and the rebuild withholds it, so one appearing
|
||||
// here means something got in around those rules or predates them, and either
|
||||
// way it is not being published and needs dealing with.
|
||||
const bad = buckets.FAILED.length + buckets.ERROR.length + buckets.UNSIGNED.length;
|
||||
console.log(
|
||||
`Summary: ${buckets.VERIFIED.length} verified, ${buckets.FAILED.length} failed, ` +
|
||||
`${buckets.UNSIGNED.length} unsigned, ${buckets.ERROR.length} error.` +
|
||||
(buckets.UNSIGNED.length ? "\nUNSIGNED records are withheld from the public list. Ask the operator to sign their\npairing payload and resubmit, or remove the listing with server/remove-listing.ts." : "") +
|
||||
(bad ? `\nNON-ZERO EXIT: ${bad} record(s) need attention.` : "\nEvery record carries a signature and every signature verifies under the current gate."));
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Launcher for the public-list rebuild, which lives in build-public.ts.
|
||||
//
|
||||
// Kept as plain JavaScript, and kept under this name, for the same reasons as
|
||||
// index.mjs:
|
||||
//
|
||||
// 1. It parses on any Node, so an operator on an older runtime gets the
|
||||
// message below rather than a syntax error from a file their Node cannot
|
||||
// execute. The check must precede the import, hence the dynamic import.
|
||||
// 2. A lot of things outside this file invoke it by name: the deploy workflow,
|
||||
// `npm run build-public`, scripts/install.mjs, and — importantly —
|
||||
// scripts/apply-update.mjs, which spawns it during a self-update. That
|
||||
// helper is the OLD copy still running while new files are swapped in, so
|
||||
// an instance updating ACROSS a rename would spawn a file that no longer
|
||||
// exists and its rebuild would fail.
|
||||
//
|
||||
// New in-process callers should import ./build-public.ts directly.
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isNaN(major) || major < 24) {
|
||||
console.error(
|
||||
`The Dojo Bay rebuild needs Node 24 or newer (found ${process.versions.node}).\n` +
|
||||
"It runs TypeScript directly, which relies on type stripping added in Node 24.\n" +
|
||||
"Upgrade Node, then re-run the rebuild.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import("./build-public.ts");
|
||||
export const rebuild = mod.rebuild;
|
||||
export const displayPaymentCode = mod.displayPaymentCode;
|
||||
export const effectiveVersion = mod.effectiveVersion;
|
||||
export const effectiveIndexer = mod.effectiveIndexer;
|
||||
export const retireUnlisted = mod.retireUnlisted;
|
||||
|
||||
// Run the rebuild when invoked directly (the .ts module's own check does not
|
||||
// fire in that case, because argv[1] is this launcher).
|
||||
import { pathToFileURL } from "node:url";
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await mod.rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge the curated seed list with APPROVED self-service submissions into the
|
||||
// public data/dojos.json that the front-end and the 10-minute updater consume.
|
||||
// The seed list (data/seed.json) stays under maintainer control; only approved
|
||||
// submissions are added. A newly-approved node inherits the status, block
|
||||
// height and reliability history the updater already recorded for it while it
|
||||
// was pending (see scripts/update.mjs and server/data/pending-probe.json), so
|
||||
// it appears active with its uptime intact the moment it is published.
|
||||
//
|
||||
// Exposes rebuild() for in-process use by the admin API; runs it when invoked
|
||||
// directly from the CLI.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { store, hasSignedBlock } from "./store.ts";
|
||||
import { urlOnDomain } from "./domains.ts";
|
||||
import type { PublicNode, PairingPayload, StoreRecord } from "../types.js";
|
||||
|
||||
/** The generated data/dojos.json. */
|
||||
interface PublicDoc {
|
||||
generated_at?: string;
|
||||
interval_minutes?: number;
|
||||
nodes: PublicNode[];
|
||||
}
|
||||
/** A history file: per-node check lists or daily rollups, keyed by record id. */
|
||||
type HistoryMap = Record<string, any>;
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function readJSON<T>(p: string, fallback: T): Promise<T> {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); }
|
||||
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e; }
|
||||
}
|
||||
// A temporary name no other writer can take. `<file>.tmp` is not atomic
|
||||
// between processes: two writers produce the same path, the first rename
|
||||
// consumes it, and the second fails with ENOENT on a file it had just written.
|
||||
// See scripts/update.mjs for the install that did exactly that.
|
||||
let tmpSeq = 0;
|
||||
async function writeAtomic(p, obj) {
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
}
|
||||
|
||||
// The payment code shown on a card. A PayNym commonly has two BIP47 variants
|
||||
// and records store every variant; the canonical one people share (and the one
|
||||
// shown on paynym.rs profiles) is the NON-segwit code, so prefer that when the
|
||||
// paynym-codes mapping can identify it, falling back to the record's first.
|
||||
// Exported for the self-test.
|
||||
/** Only the two fields it actually reads, so callers need not build a whole
|
||||
* record to ask which variant to display. */
|
||||
type CodeBearing = { paymentCodes?: string[] | null; paynym?: string | null };
|
||||
|
||||
export function displayPaymentCode(sub: CodeBearing, mapping: any): string | null {
|
||||
const codes = Array.isArray(sub.paymentCodes) ? sub.paymentCodes : [];
|
||||
if (!codes.length) return null;
|
||||
const entry = sub.paynym && mapping && mapping[sub.paynym];
|
||||
const legacy = entry && (entry.codes || []).find((c) => !c.segwit && codes.includes(c.code));
|
||||
return (legacy && legacy.code) || codes[0];
|
||||
}
|
||||
|
||||
// The version shown on a card is derived entirely from the node's API, never
|
||||
// set by an operator. In priority order:
|
||||
// 1. the version the updater last read live from the node's X-Dojo-Version
|
||||
// response header (detected_version, carried in dojos.json),
|
||||
// 2. the version in the pairing payload, used only as a bootstrap fallback
|
||||
// until the first probe reads a live header (and for older nodes that do
|
||||
// not emit the header). It is itself an API value, captured from the
|
||||
// Dojo's pairing output at submission time.
|
||||
// There is deliberately no operator override: the version always reflects what
|
||||
// the node reports. To show nothing until a live header is read, drop the
|
||||
// pairing fallback.
|
||||
export function effectiveVersion(detected: string | null | undefined, pairing: string | null | undefined): string | null {
|
||||
return detected || pairing || null;
|
||||
}
|
||||
|
||||
// The Electrum endpoint shown on a card. Only what the node reported about
|
||||
// itself: the updater reads it from the Dojo's /support/services each cycle,
|
||||
// over the API onion the operator's signature fixes.
|
||||
//
|
||||
// A URL declared in a submitted payload is NOT a fallback and must not become
|
||||
// one: nothing signs it, and a node that is healthy but exposes no indexer
|
||||
// never acquires a detected value, so a declared URL would be published for
|
||||
// good. docs/decisions.md, entry 00d07ae, has the reasoning.
|
||||
//
|
||||
// Null means the card shows N/A, which is a real answer (no exposed indexer)
|
||||
// rather than an omission, and is now reachable for every node.
|
||||
export function effectiveIndexer(detected: string | null | undefined): string | null {
|
||||
return detected || null;
|
||||
}
|
||||
|
||||
// Every key the published dojos.json may contain for a node. Exported so the
|
||||
// suite can assert on it rather than restating it, and so that adding a field
|
||||
// to toPublicNode without adding it here fails the gate: publishing a new field
|
||||
// should be a decision somebody makes, not a consequence of editing a record
|
||||
// shape somewhere else.
|
||||
export const PUBLIC_NODE_KEYS = Object.freeze([
|
||||
"id", "network", "name", "status", "paynym", "paymentCode",
|
||||
"jurisdiction", "country", "hardware", "version", "detected_version",
|
||||
"detected_indexer", "operator_domain", "operator_domain_proof",
|
||||
"block_height", "indexer_url", "checked_at", "payload", "signed",
|
||||
]);
|
||||
|
||||
// The allowlist itself, and the only producer of a published node.
|
||||
//
|
||||
// It names every field rather than deleting the ones it does not want, which is
|
||||
// the distinction that matters: a redaction list is wrong by default and has to
|
||||
// be updated whenever the store gains a field, whereas this is right by default
|
||||
// and has to be updated whenever the PUBLIC shape should change. The store
|
||||
// holds things that must never be published (moderation status, the owning
|
||||
// payment codes, submission timestamps, the probe result recorded at
|
||||
// submission, import provenance) and it will hold more in future.
|
||||
//
|
||||
// One field is copied wholesale rather than picked apart: `payload`. That is
|
||||
// deliberate, since the pairing payload including its API key is the entire
|
||||
// point of a listing and a visitor needs it byte for byte to pair. It does mean
|
||||
// the allowlist has a nested edge: anything added inside payload is published.
|
||||
// The store gate is what keeps that honest, since payload is what the operator
|
||||
// signed and the signature covers its exact contents.
|
||||
function toPublicNode(sub: StoreRecord, paymentCode: string | null): PublicNode {
|
||||
return {
|
||||
id: sub.id,
|
||||
network: sub.network,
|
||||
name: sub.name || sub.paynym || sub.id,
|
||||
status: "inactive",
|
||||
paynym: sub.paynym || null,
|
||||
paymentCode: paymentCode || null,
|
||||
jurisdiction: sub.jurisdiction || null,
|
||||
country: sub.country || null,
|
||||
hardware: sub.hardware || null,
|
||||
// Initial version is the pairing-payload fallback; rebuild() recomputes it
|
||||
// via effectiveVersion once the live-detected value is known.
|
||||
version: sub.payload?.pairing?.version || null,
|
||||
detected_version: null,
|
||||
detected_indexer: null,
|
||||
operator_domain: null,
|
||||
operator_domain_proof: null,
|
||||
block_height: null,
|
||||
indexer_url: null,
|
||||
checked_at: null,
|
||||
payload: sub.payload,
|
||||
signed: sub.signed || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Grace-period retirement for history entries. Deleting history the instant an
|
||||
// id leaves the node list turned a transient list mistake into permanent data
|
||||
// loss (the seed-migration deploy wiped every migrated node's history seconds
|
||||
// after rsync, via the post-deploy rebuild, before the migration could run on
|
||||
// the box). Instead: an unlisted id is STAMPED `retired` and kept; it is only
|
||||
// deleted after HISTORY_GRACE_DAYS (default 14); if the id is listed again
|
||||
// within the window, the stamp is cleared and its history resumes untouched.
|
||||
// Exported because scripts/update.mjs rewrites the same two files every cycle
|
||||
// and must apply identical rules.
|
||||
export function retireUnlisted(nodesMap: HistoryMap, isListed: (id: string) => boolean,
|
||||
nowIso: string, graceDays: number = Number(process.env.HISTORY_GRACE_DAYS || 14)): boolean {
|
||||
let touched = false;
|
||||
const cutoffMs = Date.parse(nowIso) - graceDays * 86400000;
|
||||
for (const id of Object.keys(nodesMap)) {
|
||||
const entry = nodesMap[id];
|
||||
if (isListed(id)) {
|
||||
if (entry.retired) { delete entry.retired; touched = true; }
|
||||
} else if (!entry.retired) {
|
||||
entry.retired = nowIso; touched = true;
|
||||
} else if (Date.parse(entry.retired) < cutoffMs) {
|
||||
delete nodesMap[id]; touched = true;
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
export async function rebuild(): Promise<{ nodes: number; approved: number; msg: string }> {
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
const SERVER_DATA = process.env.SERVER_DATA_DIR || path.join(ROOT, "server", "data");
|
||||
const SEED = path.join(DATA_DIR, "seed.json");
|
||||
const OUT = path.join(DATA_DIR, "dojos.json");
|
||||
const HIST = path.join(DATA_DIR, "history.json");
|
||||
const DAILY = path.join(DATA_DIR, "history-daily.json");
|
||||
const PENDING_PROBE = path.join(SERVER_DATA, "pending-probe.json");
|
||||
|
||||
const seed = await readJSON(SEED, { nodes: [] });
|
||||
// Optional: identifies each PayNym's non-segwit code variant for display.
|
||||
const codesDoc = await readJSON(path.join(DATA_DIR, "paynym-codes.json"), { mapping: {} });
|
||||
// The operator binding is REQUIRED: an instance must prove who runs it.
|
||||
// Warn (unmissably) rather than fail, so a malformed signature nags the
|
||||
// operator without taking the directory down for its visitors. The crypto
|
||||
// import is lazy so the dependency-free scripts/ chain can still import
|
||||
// this module on a box where server/node_modules is not installed yet.
|
||||
try {
|
||||
const opDoc = await readJSON(path.join(DATA_DIR, "operator.json"), null);
|
||||
if (!opDoc) {
|
||||
console.error("[rebuild] REQUIRED: data/operator.json is missing. Sign your onion URL with your wallet and install the binding (the installer does this); see README.");
|
||||
} else {
|
||||
try {
|
||||
const { verifyOperatorDoc } = await import("./crypto.ts");
|
||||
const v = verifyOperatorDoc(opDoc);
|
||||
if (!v.ok) console.error(`[rebuild] REQUIRED: data/operator.json does not verify: ${v.error}`);
|
||||
} catch { console.error("[rebuild] note: cannot verify operator.json (server dependencies not installed)."); }
|
||||
}
|
||||
} catch (e) { console.error(`[rebuild] operator.json check skipped: ${e.message}`); }
|
||||
|
||||
// Anchor-model checks (warnings, never fatal: a fresh instance mid-setup or
|
||||
// mid-transition should build, just noisily). The seed should hold exactly
|
||||
// one node -- the instance operator's own, carrying their payment code --
|
||||
// and every listed node should carry a BIP47 code; code-less records are
|
||||
// grandfathered exceptions managed from /admin.
|
||||
if ((seed.nodes || []).length !== 1) {
|
||||
console.error(`[rebuild] note: seed carries ${(seed.nodes || []).length} node(s); the anchor model expects exactly one (the instance operator's own node).`);
|
||||
} else if (!seed.nodes[0].paymentCode) {
|
||||
console.error(`[rebuild] REFUSING to publish the anchor seed node ${seed.nodes[0].id}: it has no BIP47 payment code.`);
|
||||
}
|
||||
// A record with no payment code and no signed pairing block is not published.
|
||||
// The store refuses to write either, so this only fires for something that
|
||||
// predates those rules or was edited by hand — and in that case it is
|
||||
// withheld rather than shown, because a listing nobody can be held to, or
|
||||
// whose details nobody has attested to, is exactly what this directory must
|
||||
// not carry. Withheld, not deleted: the record stays for a maintainer to look
|
||||
// at. The two are reported separately because the remedies differ: a missing
|
||||
// code cannot be supplied by anyone but the operator, while a missing
|
||||
// signature usually means asking them to sign what they already gave us.
|
||||
const allApproved = (await store.listSubmissions()).filter((s) => s.status === "approved");
|
||||
const codeless = allApproved.filter((s) => !(s.paymentCodes || []).length);
|
||||
if (codeless.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${codeless.length} listing(s) with no BIP47 payment code: ${codeless.map((s) => s.id).join(", ")}. A listing must carry a payment code; remove it with server/remove-listing.ts, or give it one.`);
|
||||
}
|
||||
const unsigned = allApproved.filter((s) => (s.paymentCodes || []).length && !hasSignedBlock(s));
|
||||
if (unsigned.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${unsigned.length} listing(s) with no signed pairing block: ${unsigned.map((s) => s.id).join(", ")}. Ask the operator to sign their pairing payload and resubmit, or remove the listing with server/remove-listing.ts.`);
|
||||
}
|
||||
const approvedSubs = allApproved.filter((s) => (s.paymentCodes || []).length && hasSignedBlock(s));
|
||||
const approved = approvedSubs.map((s) => toPublicNode(s, displayPaymentCode(s, codesDoc.mapping)));
|
||||
const approvedIds = new Set(approved.map((n) => n.id));
|
||||
|
||||
const byId = new Map();
|
||||
// The seed anchor is held to the same rules as any other listing.
|
||||
const seedNodes = (seed.nodes || []).filter((n) => {
|
||||
if (!n || !n.paymentCode) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no BIP47 payment code.`);
|
||||
return false;
|
||||
}
|
||||
if (!hasSignedBlock(n)) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no signed pairing block.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Seed nodes go through the SAME allowlist as store records. They used to be
|
||||
// published as they sit in data/seed.json, which meant the public file had two
|
||||
// producers and only one of them filtered anything. Nothing has ever leaked
|
||||
// that way, because seed.json is written by the installer and its fields
|
||||
// happen to be a subset of what toPublicNode emits, but "happens to be a
|
||||
// subset" is not a property anybody was maintaining: seed.json is
|
||||
// instance-owned and documented as hand-editable, so a field added there went
|
||||
// straight to the published file unread. One producer, one allowlist.
|
||||
//
|
||||
// The cast is safe because toPublicNode reads only fields a seed node has;
|
||||
// the owning code is passed as an argument rather than read from the record,
|
||||
// which is why a seed node's singular paymentCode needs no reshaping.
|
||||
for (const n of seedNodes) byId.set(n.id, toPublicNode(n as unknown as StoreRecord, n.paymentCode || null));
|
||||
for (const n of approved) byId.set(n.id, n);
|
||||
const nodes = [...byId.values()];
|
||||
|
||||
// Per-id pairing version, the bootstrap fallback used until a live version is
|
||||
// detected. The card version is never operator-set (see effectiveVersion).
|
||||
const pairingById = new Map();
|
||||
for (const n of seedNodes) pairingById.set(n.id, n.payload?.pairing?.version || null);
|
||||
for (const s of approvedSubs) pairingById.set(s.id, s.payload?.pairing?.version || null);
|
||||
|
||||
// Owner payment codes per node, for the verified-domain lookup below. The seed
|
||||
// anchor carries a single paymentCode; store records carry paymentCodes[].
|
||||
const ownerCodesById = new Map();
|
||||
for (const n of seedNodes) ownerCodesById.set(n.id, [n.paymentCode]);
|
||||
for (const sub of approvedSubs) ownerCodesById.set(sub.id, sub.paymentCodes || []);
|
||||
|
||||
// Carry over the live status the updater last wrote, so a rebuild does not
|
||||
// blank a node for a probe cycle.
|
||||
const prior = await readJSON(OUT, { nodes: [] });
|
||||
const priorById = new Map((prior.nodes || []).map((n) => [n.id, n]));
|
||||
// Pending-probe results (updater-owned): seed a just-approved node's status
|
||||
// and height from what was observed while it was pending.
|
||||
const pending = await readJSON(PENDING_PROBE, { nodes: {} });
|
||||
// Verified operator domains: published per node so the card can show the badge
|
||||
// without another lookup, and used to filter the card-title link. A link that
|
||||
// is not on the operator's verified domain is withheld rather than deleted, so
|
||||
// an operator who verifies later gets their link back untouched.
|
||||
const domainByCode = await store.verifiedDomainMap();
|
||||
// The proof is published alongside the badge so a reader can check it with
|
||||
// their own tools instead of taking our tick on trust: the TXT record proves
|
||||
// the domain names the payment code, and the signed statement proves the code
|
||||
// names the domain. Everything here is already public (the payment code is on
|
||||
// the card, the domain is the claim), so publishing it discloses nothing new.
|
||||
const claimByCode = new Map<string, { signed: string; verified_at: string | null }>();
|
||||
for (const c of await store.listDomains()) {
|
||||
if (c?.verified && c.domain) claimByCode.set(c.paymentCode, { signed: c.signed, verified_at: c.verified_at ?? null });
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const codes = ownerCodesById.get(n.id) || [];
|
||||
const code = codes.find((c) => domainByCode.get(c)) || null;
|
||||
const domain = code ? domainByCode.get(code) || null : null;
|
||||
n.operator_domain = domain;
|
||||
const claim = code ? claimByCode.get(code) : null;
|
||||
n.operator_domain_proof = domain && claim ? {
|
||||
domain,
|
||||
paymentCode: code,
|
||||
txt_name: `_dojobay.${domain}`,
|
||||
txt_value: `dojobay-domain-v1 pm=${code}`,
|
||||
signed: claim.signed,
|
||||
verified_at: claim.verified_at,
|
||||
} : null;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const p = priorById.get(n.id);
|
||||
const pr = (!p && approvedIds.has(n.id)) ? pending.nodes?.[n.id] : null;
|
||||
if (p) {
|
||||
n.status = p.status ?? n.status;
|
||||
n.checked_at = p.checked_at ?? n.checked_at;
|
||||
if (p.block_height != null) n.block_height = p.block_height;
|
||||
} else if (pr) {
|
||||
n.status = pr.status ?? n.status;
|
||||
n.checked_at = pr.checked_at ?? n.checked_at;
|
||||
if (pr.block_height != null) n.block_height = pr.block_height;
|
||||
}
|
||||
// Carry the live-detected version (prior snapshot, then a just-approved
|
||||
// node's pending probe) and fold it into the effective card version. The
|
||||
// updater writes detected_version each cycle; a rebuild must preserve it,
|
||||
// exactly as it preserves status and block height.
|
||||
const detected = (p && p.detected_version) || (pr && pr.detected_version) || null;
|
||||
n.detected_version = detected;
|
||||
n.version = effectiveVersion(detected, pairingById.get(n.id));
|
||||
// Same treatment for the Electrum endpoint: carry what the updater read and
|
||||
// publish it as indexer_url, which the card renders (N/A when null).
|
||||
const detectedIdx = (p && p.detected_indexer) || (pr && pr.detected_indexer) || null;
|
||||
n.detected_indexer = detectedIdx;
|
||||
n.indexer_url = effectiveIndexer(detectedIdx);
|
||||
}
|
||||
|
||||
await writeAtomic(OUT, {
|
||||
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||
interval_minutes: 10,
|
||||
nodes,
|
||||
});
|
||||
|
||||
// Reliability history: ensure a bucket per node, seed a newly-approved node's
|
||||
// history from its pending history, and retire (grace period) unlisted ids.
|
||||
const hist = await readJSON(HIST, { interval_minutes: 10, window_checks: 144, nodes: {} });
|
||||
let touched = false;
|
||||
for (const n of nodes) {
|
||||
if (!hist.nodes[n.id]) {
|
||||
const seedChecks = (approvedIds.has(n.id) && pending.nodes?.[n.id]?.checks) || [];
|
||||
hist.nodes[n.id] = { checks: seedChecks.slice() };
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
touched = retireUnlisted(hist.nodes, (id) => byId.has(id), nowIso) || touched;
|
||||
if (touched) { (hist as any).generated_at = (hist as any).generated_at || null; await writeAtomic(HIST, hist); }
|
||||
|
||||
// 90-day daily rollup membership.
|
||||
const dailyDoc = await readJSON(DAILY, { retention_days: 90, nodes: {} });
|
||||
let dailyTouched = false;
|
||||
for (const n of nodes) if (!dailyDoc.nodes[n.id]) {
|
||||
dailyDoc.nodes[n.id] = { days: (approvedIds.has(n.id) && pending.nodes?.[n.id]?.days) ? pending.nodes[n.id].days.slice() : [] };
|
||||
dailyTouched = true;
|
||||
}
|
||||
dailyTouched = retireUnlisted(dailyDoc.nodes, (id) => byId.has(id), nowIso) || dailyTouched;
|
||||
if (dailyTouched) await writeAtomic(DAILY, dailyDoc);
|
||||
|
||||
const msg = `public list rebuilt: ${nodes.length} nodes (${approved.length} approved submissions).`;
|
||||
return { nodes: nodes.length, approved: approved.length, msg };
|
||||
}
|
||||
|
||||
// Run when invoked directly.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — resource diagnostic.
|
||||
//
|
||||
// READ-ONLY. Measures what this instance actually uses, rather than guessing,
|
||||
// so an operator can size a VPS from evidence and this project can document a
|
||||
// requirement it has tested.
|
||||
//
|
||||
// What it looks at, and why each matters for THIS workload:
|
||||
//
|
||||
// memory the backend is a small long-running Node process; the updater is
|
||||
// a second one every ten minutes; tor and nginx sit alongside.
|
||||
// Peak matters more than current, because `npm ci` during a deploy
|
||||
// and the unzip during a self-update are the two spikes.
|
||||
// disk node_modules, the published data, and — the one that grows
|
||||
// without limit — data/backups, a full copy of the code kept by
|
||||
// every self-update.
|
||||
// cpu idle almost always, with a burst each probe cycle: one Tor
|
||||
// circuit per listed node, plus secp256k1 verification.
|
||||
// strain swap in use, OOM kills and load average are the evidence that a
|
||||
// box is actually too small, as opposed to merely modest.
|
||||
//
|
||||
// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and
|
||||
// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and
|
||||
// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and
|
||||
// which is a real if narrow bug: a value beginning with a hyphen is read by
|
||||
// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures
|
||||
// something other than what was asked for. Both are now answered by Node
|
||||
// itself, statfs() and a walk, which removes the class rather than escaping
|
||||
// around it. The two subprocesses that remain (systemctl, journalctl) exist
|
||||
// because nothing in Node can answer what they answer, and both take arguments
|
||||
// written here. Keep it that way: see sh() below.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server && node check-resources.ts
|
||||
// =============================================================================
|
||||
import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, "..");
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data");
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const mb = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB";
|
||||
return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB";
|
||||
};
|
||||
const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB";
|
||||
const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } };
|
||||
|
||||
// Every call site passes a command and an argument list written in this file,
|
||||
// never a path, a name or anything else derived from the environment. The one
|
||||
// exception is UNITS below, which the suite checks directly. A future edit that
|
||||
// interpolates a variable in here fails the gate rather than shipping.
|
||||
const sh = async (cmd: string, args: string[]) => {
|
||||
try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; }
|
||||
};
|
||||
|
||||
// The only values this file passes to a subprocess that are not written inline
|
||||
// at the call site. They are exported so the suite can assert on the array
|
||||
// itself rather than reading this source and guessing: an assertion about what
|
||||
// a program does is worth more than one about how it is spelled.
|
||||
export const UNITS = [
|
||||
"dojobay-server.service",
|
||||
"dojobay-update.service",
|
||||
"tor.service",
|
||||
"nginx.service",
|
||||
];
|
||||
|
||||
// Replaces `df`. statfs reports the filesystem holding the path, and the
|
||||
// arithmetic matches what df prints: used counts the blocks the filesystem
|
||||
// considers occupied, while available excludes the root reserve, so used plus
|
||||
// available is legitimately less than the total.
|
||||
export const diskUsage = async (p: string) => {
|
||||
try {
|
||||
const fs = await statfs(p);
|
||||
const block = Number(fs.bsize);
|
||||
return {
|
||||
size: Number(fs.blocks) * block,
|
||||
used: (Number(fs.blocks) - Number(fs.bfree)) * block,
|
||||
avail: Number(fs.bavail) * block,
|
||||
};
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// Replaces `du -sb`: apparent size of a tree, symlinks counted but never
|
||||
// followed, unreadable entries skipped rather than fatal, and directory inodes
|
||||
// excluded, which is what `du -sb` does and is why this agrees with it to the
|
||||
// byte on a real node_modules. Counting the directories instead would add 4 KB
|
||||
// per directory of filesystem bookkeeping to a figure meant to describe
|
||||
// content. One difference remains: du counts a hard-linked file once, this
|
||||
// counts it once per link, which node_modules does not contain and which would
|
||||
// overstate rather than hide. It also walks in JavaScript, so a populated
|
||||
// node_modules takes a second or so rather than being instant, which is nothing
|
||||
// for a diagnostic run by hand a few times a year.
|
||||
export const dirSize = async (p: string): Promise<number | null> => {
|
||||
const root = await lstat(p).catch(() => null);
|
||||
if (!root) return null;
|
||||
if (!root.isDirectory()) return root.size;
|
||||
let total = 0;
|
||||
const walk = async (dir: string) => {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { await walk(full); continue; }
|
||||
const s = await lstat(full).catch(() => null);
|
||||
if (s) total += s.size;
|
||||
}
|
||||
};
|
||||
await walk(p);
|
||||
return total;
|
||||
};
|
||||
|
||||
const report = async () => {
|
||||
console.log("The Dojo Bay — what this instance actually uses\n");
|
||||
|
||||
// ---- the machine ----------------------------------------------------------
|
||||
const meminfo = (await read("/proc/meminfo")) || "";
|
||||
const kb = (key: string) => {
|
||||
const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m"));
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable");
|
||||
const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree");
|
||||
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null;
|
||||
const cpus = os.cpus();
|
||||
|
||||
console.log("MACHINE");
|
||||
console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`);
|
||||
console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`);
|
||||
console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`);
|
||||
const la = os.loadavg();
|
||||
console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`);
|
||||
|
||||
const disk = await diskUsage(WEB_ROOT);
|
||||
const diskFree = disk ? disk.avail : null;
|
||||
if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`);
|
||||
|
||||
// ---- what our services use ------------------------------------------------
|
||||
console.log("\nSERVICES (current / peak since boot)");
|
||||
let ourPeak = 0;
|
||||
for (const unit of UNITS) {
|
||||
const base = `/sys/fs/cgroup/system.slice/${unit}`;
|
||||
const cur = Number((await read(`${base}/memory.current`)) || 0);
|
||||
const peak = Number((await read(`${base}/memory.peak`)) || 0);
|
||||
const active = await sh("systemctl", ["is-active", unit]);
|
||||
if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; }
|
||||
if (unit.startsWith("dojobay")) ourPeak += peak || cur;
|
||||
console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`);
|
||||
}
|
||||
|
||||
// ---- disk, broken down ----------------------------------------------------
|
||||
console.log("\nDISK USED BY THIS INSTALLATION");
|
||||
const parts: [string, string][] = [
|
||||
["everything", WEB_ROOT],
|
||||
[" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")],
|
||||
[" data (published)", PUBLIC_DIR],
|
||||
[" data/avatars", path.join(PUBLIC_DIR, "avatars")],
|
||||
[" data/backups", path.join(PUBLIC_DIR, "backups")],
|
||||
[" data/updates", path.join(PUBLIC_DIR, "updates")],
|
||||
];
|
||||
let backupsBytes = 0, backupCount = 0;
|
||||
for (const [label, p] of parts) {
|
||||
const bytes = await dirSize(p);
|
||||
if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; }
|
||||
if (label.includes("backups")) {
|
||||
backupsBytes = bytes;
|
||||
try { backupCount = (await readdir(p)).length; } catch { /* none */ }
|
||||
}
|
||||
console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`);
|
||||
}
|
||||
|
||||
// ---- the workload ---------------------------------------------------------
|
||||
console.log("\nWORKLOAD");
|
||||
let nodeCount = 0, intervalMin = 10;
|
||||
try {
|
||||
const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}");
|
||||
nodeCount = (dojos.nodes || []).length;
|
||||
intervalMin = Number(dojos.interval_minutes) || 10;
|
||||
} catch { /* not built yet */ }
|
||||
const concurrency = Number(process.env.CONCURRENCY || 4);
|
||||
console.log(` listed nodes ${nodeCount}`);
|
||||
console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`);
|
||||
for (const f of ["dojos.json", "history.json", "history-daily.json"]) {
|
||||
const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null);
|
||||
if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`);
|
||||
}
|
||||
|
||||
// ---- evidence of strain ---------------------------------------------------
|
||||
// journalctl does its own matching, so there is no pipeline and no shell: the
|
||||
// filter is an argument, the output is one line per matching entry, and a
|
||||
// journalctl that cannot answer leaves this null exactly as an absent one did.
|
||||
console.log("\nSIGNS OF STRAIN");
|
||||
const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false",
|
||||
"--grep=out of memory", "--output=cat"]);
|
||||
const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0;
|
||||
const findings: string[] = [];
|
||||
if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`);
|
||||
if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`);
|
||||
if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now");
|
||||
if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`);
|
||||
if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`);
|
||||
if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`);
|
||||
if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything.");
|
||||
else for (const f of findings) console.log(` · ${f}`);
|
||||
|
||||
// ---- what to tell other operators -----------------------------------------
|
||||
console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC");
|
||||
const ourMb = ourPeak / MB;
|
||||
if (ourPeak > 0) {
|
||||
console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`);
|
||||
console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`");
|
||||
console.log(" during a deploy, which is the largest transient by some way.");
|
||||
} else {
|
||||
console.log(" The services are not running here, so nothing was measured. Run this ON");
|
||||
console.log(" the instance, with the backend up, for numbers that mean anything.");
|
||||
}
|
||||
console.log("");
|
||||
console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`);
|
||||
console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory");
|
||||
console.log(" and a little disk headroom are what matter. Run this again after a");
|
||||
console.log(" deploy and after a self-update to catch the peaks rather than the calm.");
|
||||
};
|
||||
|
||||
// Run when invoked, importable when tested. The suite exercises dirSize and
|
||||
// diskUsage directly; printing a report on import would make that impossible.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report();
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — report the Dojo version of every listing.
|
||||
//
|
||||
// READ-ONLY. Nothing is written and no network is touched: it reads what the
|
||||
// updater has already recorded.
|
||||
//
|
||||
// Two versions per node, and the difference matters when choosing a minimum:
|
||||
//
|
||||
// detected from the node's own X-Dojo-Version header, read on every probe.
|
||||
// This is what it is actually running.
|
||||
// declared the version inside the pairing payload. Frozen when that payload
|
||||
// was generated and signed, so it can be years out of date while
|
||||
// the node itself is current. At least one listing here declares
|
||||
// 1.4.5 for exactly that reason.
|
||||
//
|
||||
// A minimum-version rule should therefore judge the DETECTED version. This
|
||||
// report shows both, so a threshold can be chosen against the real spread.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node check-versions.ts # against the configured minimum
|
||||
// node check-versions.ts 1.27.0 # against a threshold you are weighing
|
||||
// =============================================================================
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { store } from "./store.ts";
|
||||
import { MIN_DOJO_VERSION, judgeVersion, compareVersions } from "./dojo-version.ts";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
|
||||
const minimum = (process.argv.find((a) => /^\d/.test(a)) || MIN_DOJO_VERSION || "1.27.0").trim();
|
||||
|
||||
const dojos = await readFile(path.join(PUBLIC_DIR, "dojos.json"), "utf8")
|
||||
.then((t) => JSON.parse(t)).catch(() => ({ nodes: [] }));
|
||||
const published = new Map((dojos.nodes || []).map((n: any) => [n.id, n]));
|
||||
const records = (await store.listSubmissions())
|
||||
.filter((r) => r.status === "approved")
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const rows = records.map((r) => {
|
||||
const pub: any = published.get(r.id) || {};
|
||||
const detected = pub.detected_version || null;
|
||||
const declared = r.payload?.pairing?.version || null;
|
||||
const verdict = judgeVersion(detected, declared, minimum);
|
||||
return { id: r.id, name: r.name || r.id, detected, declared, verdict, status: pub.status || "?" };
|
||||
});
|
||||
|
||||
const pad = (s: string, n: number) => (s || "").padEnd(n);
|
||||
console.log(`Minimum being applied: ${minimum}\n`);
|
||||
console.log(pad("RECORD", 30) + pad("DETECTED", 12) + pad("DECLARED", 12) + pad("NODE", 10) + "VERDICT");
|
||||
console.log("-".repeat(78));
|
||||
for (const r of rows) {
|
||||
const v = r.verdict.ok ? "ok" : (r.verdict.version ? "BELOW MINIMUM" : "no version reported");
|
||||
console.log(pad(r.id, 30) + pad(r.detected || "—", 12) + pad(r.declared || "—", 12) + pad(r.status, 10) + v);
|
||||
}
|
||||
|
||||
const below = rows.filter((r) => !r.verdict.ok && r.verdict.version);
|
||||
const unknown = rows.filter((r) => !r.verdict.ok && !r.verdict.version);
|
||||
const ok = rows.length - below.length - unknown.length;
|
||||
|
||||
console.log(`\n${ok} at or above ${minimum}, ${below.length} below, ${unknown.length} with no version reported.`);
|
||||
if (below.length) {
|
||||
console.log("\nBelow the minimum:");
|
||||
for (const r of below) console.log(` ${r.id}: ${r.verdict.version} (${r.verdict.source})`);
|
||||
}
|
||||
if (unknown.length) {
|
||||
console.log("\nNo version reported. A node that has never been probed successfully shows nothing here,");
|
||||
console.log("so check whether these are down rather than old before reading anything into it:");
|
||||
for (const r of unknown) console.log(` ${r.id} (node currently ${r.status})`);
|
||||
}
|
||||
|
||||
// The spread, which is what a threshold should actually be chosen against.
|
||||
const seen = rows.map((r) => r.detected).filter(Boolean) as string[];
|
||||
if (seen.length) {
|
||||
const uniq = [...new Set(seen)].sort(compareVersions);
|
||||
console.log(`\nDetected versions in use: ${uniq.join(", ")}`);
|
||||
console.log(`Oldest running: ${uniq[0]}. A minimum above that would refuse a node currently listed,`);
|
||||
console.log("though existing listings are never re-judged — the check applies to new submissions.");
|
||||
}
|
||||
process.exit(below.length || unknown.length ? 1 : 0);
|
||||
@@ -0,0 +1,429 @@
|
||||
// Auth47 login and BIP47 signed-payload verification for The Dojo Bay backend.
|
||||
// Thin wrappers over the audited Samourai libraries; the exact call shapes here
|
||||
// were verified against the libraries end to end (see selftest.mjs).
|
||||
import { Auth47Verifier } from "@dojo-tools/auth47";
|
||||
import { BIP47Factory } from "@dojo-tools/bip47";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
/** Outcome of a signature check: either accepted, or refused with a reason an
|
||||
* operator can act on. */
|
||||
export type VerifyResult =
|
||||
| { ok: true; error?: undefined; address?: string; paymentCode?: string | null }
|
||||
| { ok: false; error: string; address?: undefined; paymentCode?: undefined };
|
||||
|
||||
/** The parts of a wallet-exported signed block. */
|
||||
export interface ParsedBlock {
|
||||
/** Everything the signature covers, including the BIP47 tail. */
|
||||
message: string;
|
||||
/** The pairing JSON alone. */
|
||||
pairingText: string;
|
||||
/** The payment code inside the signed text, when present. */
|
||||
paymentCode: string | null;
|
||||
address: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const bip47 = BIP47Factory(ecc);
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
|
||||
// ---- Auth47 ----------------------------------------------------------------
|
||||
// The verifier needs to know its own callback URL. We build it from the site's
|
||||
// base URL (the .onion origin) at construction time.
|
||||
export function makeAuth47(baseUrl) {
|
||||
const callback = new URL("/api/auth47/callback", baseUrl).toString();
|
||||
const verifier = new Auth47Verifier(ecc, callback);
|
||||
|
||||
// Full challenge URI shown to the wallet (includes the callback `c`).
|
||||
function challengeURI(nonce, expires, resource) {
|
||||
return verifier.generateURI({ nonce, expires, resource });
|
||||
}
|
||||
|
||||
// Per the spec, the wallet signs the challenge WITHOUT the callback param.
|
||||
// Given the full URI we generated, produce the value the proof must contain.
|
||||
function signedForm(fullUri) {
|
||||
const u = new URL(fullUri);
|
||||
u.searchParams.delete("c");
|
||||
return decodeURIComponent(u.toString());
|
||||
}
|
||||
|
||||
// Two URLs naming the same resource. Compared as parsed URLs rather than as
|
||||
// strings, so a trailing slash or a difference in host case is not treated as
|
||||
// a different site, while a different origin or path is. Anything that does
|
||||
// not parse is not equal to anything.
|
||||
function sameResource(a: string, b: string): boolean {
|
||||
try {
|
||||
const norm = (u: string) => {
|
||||
const x = new URL(u);
|
||||
return x.origin.toLowerCase() + x.pathname.replace(/\/+$/, "") + x.search;
|
||||
};
|
||||
return norm(a) === norm(b);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Verify a posted proof. Returns { ok, paymentCode } or { ok:false, error }.
|
||||
//
|
||||
// expectedResource is REQUIRED, and the shape is the point. A signature is
|
||||
// only ever evidence of what it was made over, so a verifier that takes only
|
||||
// the thing being verified can answer "is this signed?" but never "is this
|
||||
// signed FOR ME?". The other three verifiers in this file all take an
|
||||
// expectation for that reason: verifySignedPayload takes expectedMessage and
|
||||
// expectedAddress, verifySignedUrlClaim takes expectedUrl, verifyOperatorDoc
|
||||
// takes expectedOnion. This one did not, and the missing binding was
|
||||
// invisible rather than a missing argument.
|
||||
//
|
||||
// What it prevents: the library checks that the challenge's r parameter is a
|
||||
// well-formed http(s) URL, but it cannot know which URL is ours. Without this
|
||||
// comparison an attacker could take a live nonce from this instance, show a
|
||||
// victim the same challenge with r rewritten to their own site, and relay the
|
||||
// resulting proof back here. The victim's wallet would display the attacker's
|
||||
// site, the signature would verify, and a session would be minted here in the
|
||||
// victim's name. The r parameter exists so a person can see what they are
|
||||
// signing into, and this check is what makes that display mean anything.
|
||||
function verify(proof: unknown, { expectedResource }: { expectedResource?: string } = {}): VerifyResult {
|
||||
// Fail closed rather than throwing: a caller who forgot this is a bug, but
|
||||
// a 500 from an auth endpoint is a worse way to find out than a refusal
|
||||
// that names the omission.
|
||||
if (!expectedResource) {
|
||||
return { ok: false, error: "internal: no expected resource supplied, refusing to verify an unbound proof" };
|
||||
}
|
||||
const res = verifier.verifyProof(proof);
|
||||
if (res.result !== "ok") return { ok: false, error: res.error };
|
||||
// Read the resource from the challenge the signature actually covers, not
|
||||
// from anything the caller passed alongside it.
|
||||
const challenge = (proof as { challenge?: unknown }).challenge;
|
||||
let resource: string | null = null;
|
||||
try { resource = new URL(String(challenge)).searchParams.get("r"); } catch { /* unparseable */ }
|
||||
if (!resource || !sameResource(resource, expectedResource)) {
|
||||
return { ok: false, error: `proof was signed for a different site (${resource || "no resource"}), not this one` };
|
||||
}
|
||||
// Auth47 defines two proof shapes: a nym proof carrying a payment code, and
|
||||
// an address proof carrying a plain address. Only the former identifies an
|
||||
// operator here, and reading .nym off the wrong one would bind a session to
|
||||
// undefined, so require it explicitly rather than assuming.
|
||||
const nym = (res.data as { nym?: string }).nym;
|
||||
if (typeof nym !== "string" || !nym) {
|
||||
return { ok: false, error: "proof does not carry a payment code (an address proof cannot identify an operator)" };
|
||||
}
|
||||
return { ok: true, paymentCode: nym };
|
||||
}
|
||||
|
||||
return { challengeURI, signedForm, verify, callback };
|
||||
}
|
||||
|
||||
// ---- payment code -> notification address ----------------------------------
|
||||
export function notificationAddress(paymentCode: string, network: string = "bitcoin"): string {
|
||||
const net = bip47utils.networks[network];
|
||||
return bip47.fromBase58(paymentCode, net).getNotificationAddress();
|
||||
}
|
||||
|
||||
// The exact text an operator signs to attest to a pairing payload, and the
|
||||
// exact text every gate checks a signature against.
|
||||
//
|
||||
// It lives here because it had grown two copies, in the submission gate and in
|
||||
// audit-signed.mjs, the second carrying a comment warning that it MUST mirror
|
||||
// the first. A canonical message that exists twice is a canonical message
|
||||
// waiting to disagree with itself, and the failure would be quiet in the worst
|
||||
// direction: signatures accepted at submission and reported as invalid by a
|
||||
// later audit, or the reverse. The installer needs it too, which would have
|
||||
// made three.
|
||||
export function canonicalPairing(payload: { pairing?: unknown; explorer?: unknown } | null | undefined): string {
|
||||
return JSON.stringify({ pairing: payload?.pairing, explorer: payload?.explorer });
|
||||
}
|
||||
|
||||
// Every address a given payment code could legitimately have signed from.
|
||||
// A PayNym is a MAINNET identity: an operator listing a testnet node still
|
||||
// signs with their mainnet notification address, because that is the only key
|
||||
// their wallet holds for that code. Deriving on testnet yields an "m…" address
|
||||
// that can never match, which silently made every testnet listing unverifiable.
|
||||
// Both derivations come from the same code, so accepting either is no weaker.
|
||||
export function notificationAddresses(paymentCode: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const net of ["bitcoin", "testnet"]) {
|
||||
try { const a = notificationAddress(paymentCode, net); if (!out.includes(a)) out.push(a); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- lab-style signed pairing payload verification -------------------------
|
||||
// The submitted `signed` blob is a BIP-signed message. We require it to be
|
||||
// signed by the notification address of the operator's authenticated payment
|
||||
// code, over the exact pairing JSON they are submitting. This is the same
|
||||
// verify() the paymentcode.io lab uses.
|
||||
//
|
||||
// The signed message format Samourai/Ashigaru export wraps the payload between
|
||||
// BEGIN/END markers. CRITICAL, verified against a real wallet export: the text
|
||||
// the wallet signs is EVERYTHING between the markers, i.e. the pairing JSON
|
||||
// PLUS the trailing "BIP47:" line and payment code (no trailing newline). An
|
||||
// earlier revision excised the BIP47 tail before verifying, which made every
|
||||
// genuine wallet signature fail as "invalid signature"; the selftest did not
|
||||
// catch it because it constructed its own blocks under the same assumption.
|
||||
// Because the BIP47 line is inside the signed text, the payment code is
|
||||
// covered by the signature and can itself be verified against the signing
|
||||
// address (see verifySignedPayload).
|
||||
// Repair a signed block whose whitespace was mangled in transit.
|
||||
//
|
||||
// The signature covers the exact bytes between the markers, and the blank line
|
||||
// before the "BIP47:" line is part of them. Copying a block through a chat
|
||||
// window, a web form or a mail client routinely collapses that blank line, at
|
||||
// which point a perfectly good signature stops verifying and the operator is
|
||||
// told their signature is invalid, which is both wrong and unhelpful.
|
||||
//
|
||||
// This is safe rather than a fudge: a candidate is accepted ONLY if it verifies
|
||||
// cryptographically against an address the declared payment code derives, so
|
||||
// nothing is taken on trust. The repaired block is what gets stored, so later
|
||||
// audits verify too. Returns null when no candidate verifies.
|
||||
export function repairSignedBlock(text: unknown): { block: string; note: string | null } | null {
|
||||
const raw = String(text || "").replace(/\r\n/g, "\n");
|
||||
const addrM = raw.match(/Address:\s*(\S+)/);
|
||||
const sigM = raw.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
const innerM = raw.match(/SIGNED MESSAGE-----[ \t]*\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
if (!addrM || !sigM || !innerM) return null;
|
||||
const address = addrM[1].trim(), signature = sigM[1].trim();
|
||||
const inner = innerM[1];
|
||||
const codeM = inner.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
const json = inner.replace(/\n*[ \t]*BIP47:[\s\S]*$/, "").replace(/\n+$/, "");
|
||||
const code = codeM ? codeM[1] : null;
|
||||
|
||||
const candidates: [string, string][] = [["", inner]];
|
||||
if (code) {
|
||||
candidates.push(
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47: ${code}`],
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47:\n${code}`],
|
||||
);
|
||||
}
|
||||
const accept = code ? notificationAddresses(code) : [];
|
||||
if (code && !accept.includes(address)) return null; // the code does not own this address
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
for (const [note, candidate] of candidates) {
|
||||
let ok = false;
|
||||
try { ok = message.verify(candidate, address, signature, net.messagePrefix); } catch { ok = false; }
|
||||
if (!ok) continue;
|
||||
const block = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${candidate}\n` +
|
||||
`-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${address}\n\n${signature}\n` +
|
||||
`-----END BITCOIN SIGNATURE-----`;
|
||||
return { block, note: note || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSignedBlock(text: unknown): ParsedBlock | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const t = text.replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+/=]{80,})\n-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) return null;
|
||||
const message = msgM[1].trim(); // the full signed text
|
||||
const tail = message.match(/^([\s\S]*?)\n\s*BIP47:\s*\n?(\S+)$/);
|
||||
return {
|
||||
message, // what the signature covers
|
||||
pairingText: tail ? tail[1].trim() : message, // the pairing JSON alone
|
||||
paymentCode: tail ? tail[2] : null, // code inside the signed text
|
||||
address: addrM[1].trim(),
|
||||
signature: sigM[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify a signed pairing block. Checks, in order, with distinct errors:
|
||||
// 1. the block parses at all;
|
||||
// 2. the pairing JSON inside it matches the payload being submitted;
|
||||
// 3. the signature is cryptographically valid over the FULL signed text;
|
||||
// 4. (signature now known valid) the BIP47 payment code inside the signed
|
||||
// text is a valid code whose notification address IS the signing address;
|
||||
// 5. the signing address matches the authenticated payment code's
|
||||
// notification address (the session binding the API supplies).
|
||||
// Does the signed pairing text describe the same payload being submitted?
|
||||
//
|
||||
// Wallets and admin panels serialise this JSON differently: pretty-printed with
|
||||
// newlines and indentation, or with the object keys in another order. All of
|
||||
// those are the SAME payload, and a byte-exact comparison against our own
|
||||
// re-serialisation rejects them, which is what made genuine, correctly signed
|
||||
// listings fail the gate. So compare the parsed structures instead: identical
|
||||
// keys and identical values, order-insensitive, at every level. Anything that
|
||||
// is not valid JSON, or that differs in any value or key, still fails.
|
||||
export function sameSignedPayload(signedText: string, expected: string): boolean {
|
||||
const a = String(signedText).trim(), b = String(expected).trim();
|
||||
if (a === b) return true;
|
||||
let pa, pb;
|
||||
try { pa = JSON.parse(a); pb = JSON.parse(b); } catch { return false; }
|
||||
return stableStringify(pa) === stableStringify(pb);
|
||||
}
|
||||
|
||||
function stableStringify(v: unknown): string {
|
||||
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v) ?? "null";
|
||||
}
|
||||
|
||||
export function verifySignedPayload({ signedText, expectedMessage, expectedAddress, network = "bitcoin" }: {
|
||||
signedText: string;
|
||||
expectedMessage?: string | null;
|
||||
expectedAddress?: string | string[] | null;
|
||||
network?: string;
|
||||
}): VerifyResult {
|
||||
const parsed = parseSignedBlock(signedText);
|
||||
if (!parsed) return { ok: false, error: "unrecognised signed message format" };
|
||||
if (expectedMessage != null && !sameSignedPayload(parsed.pairingText, expectedMessage)) {
|
||||
return { ok: false, error: "signed message does not match the submitted pairing code" };
|
||||
}
|
||||
const net = bip47utils.networks[network];
|
||||
let verified = false;
|
||||
try {
|
||||
verified = message.verify(parsed.message, parsed.address, parsed.signature, net.messagePrefix);
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
if (!verified) return { ok: false, error: "invalid signature" };
|
||||
if (parsed.paymentCode) {
|
||||
const derived = notificationAddresses(parsed.paymentCode);
|
||||
if (!derived.length) {
|
||||
return { ok: false, error: "signature is valid, but the BIP47 line inside the signed message is not a valid payment code" };
|
||||
}
|
||||
if (!derived.includes(parsed.address)) {
|
||||
return { ok: false, error: "signature is valid, but the signing address is not the notification address of the payment code inside the message" };
|
||||
}
|
||||
}
|
||||
// expectedAddress may be a single address or every address the authenticated
|
||||
// code could have signed from (see notificationAddresses).
|
||||
const accept = expectedAddress == null ? null : (Array.isArray(expectedAddress) ? expectedAddress : [expectedAddress]);
|
||||
if (accept && !accept.includes(parsed.address)) {
|
||||
return { ok: false, error: "signed by a different address than the authenticated payment code" };
|
||||
}
|
||||
return { ok: true, address: parsed.address, paymentCode: parsed.paymentCode };
|
||||
}
|
||||
|
||||
// ---- operator binding (data/operator.json) ----------------------------------
|
||||
// A Dojo Bay instance MUST prove who runs it: operator.json binds the onion
|
||||
// address to the operator's payment code via a wallet signature over the text
|
||||
//
|
||||
// http://<onion>/
|
||||
//
|
||||
// BIP47: <payment code>
|
||||
//
|
||||
// (unlike pairing blocks, the BIP47 line here is INSIDE the signed message:
|
||||
// the operator pastes the whole text into the wallet's Sign tool). Verified at
|
||||
// install, at bootstrap import before trusting a remote instance's data, and
|
||||
// on every rebuild.
|
||||
// ---- signed URL claims -----------------------------------------------------
|
||||
// A verified operator domain is proven the same way the instance's own onion is:
|
||||
// the operator signs the URL, a blank line, then "BIP47: <their code>". Same
|
||||
// shape, same wallet procedure (PayNym → Sign message), so nothing new to learn
|
||||
// and no new crypto. This is deliberately a separate field from the pairing
|
||||
// payload: the pairing block attests to pairing data only, and operators
|
||||
// stuffing identity material into it is exactly what this feature replaces.
|
||||
export function claimText(url: string, paymentCode: string): string {
|
||||
return `${String(url).replace(/\/+$/, "")}/\n\nBIP47: ${paymentCode}`;
|
||||
}
|
||||
|
||||
// Verify a signed claim over `expectedUrl` by `paymentCode`. Returns
|
||||
// { ok } or { ok: false, error } with errors an operator can act on.
|
||||
export function verifySignedUrlClaim({ signed, expectedUrl, paymentCode }: {
|
||||
signed: string;
|
||||
expectedUrl: string;
|
||||
paymentCode: string;
|
||||
}): VerifyResult {
|
||||
if (!signed) return { ok: false, error: "no signed block supplied" };
|
||||
if (!paymentCode) return { ok: false, error: "no payment code supplied" };
|
||||
const t = String(signed).replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `not a recognisable signed block (missing ${missing}) — the paste may have been truncated` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(expectedUrl)) {
|
||||
return { ok: false, error: `the signed message starts with ${firstLine || "(nothing)"}, but this claim is for ${expectedUrl}` };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM) return { ok: false, error: "the signed message has no BIP47: line" };
|
||||
if (bipM[1] !== paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message is a different payment code from the one you are signed in with" };
|
||||
}
|
||||
const accept = notificationAddresses(paymentCode);
|
||||
if (!accept.includes(addrM[1].trim())) {
|
||||
return { ok: false, error: `signed by ${addrM[1].trim()}, but your payment code's notification address is ${accept[0]} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
try {
|
||||
if (!message.verify(signedMessage, addrM[1].trim(), sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
return { ok: true, address: addrM[1].trim() };
|
||||
}
|
||||
|
||||
export function verifyOperatorDoc(doc: any, { expectedOnion }: { expectedOnion?: string } = {}): VerifyResult {
|
||||
if (!doc || typeof doc !== "object") return { ok: false, error: "operator.json missing or unreadable" };
|
||||
if (!doc.paymentCode) return { ok: false, error: "operator.json has no paymentCode" };
|
||||
if (!doc.verifySigned) return { ok: false, error: "operator.json has no verifySigned block" };
|
||||
const t = String(doc.verifySigned).replace(/\r\n/g, "\n");
|
||||
// The newline after the BEGIN marker is optional: some terminals swallow it
|
||||
// when a block is pasted. It is not part of the signed text either way, so
|
||||
// tolerating it recovers the correct message rather than changing it.
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
// Name what is missing: a truncated or line-dropped paste is by far the
|
||||
// most common cause, and "not recognisable" alone sends people hunting
|
||||
// for a problem with their wallet instead of re-pasting.
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `verifySigned is not a recognisable signed block (missing ${missing}) — the paste may have been truncated; paste the whole block again` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "");
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(doc.onion)) return { ok: false, error: "signed message does not match the declared onion" };
|
||||
if (expectedOnion && norm(doc.onion) !== norm(expectedOnion)) {
|
||||
return { ok: false, error: "declared onion does not match the address this document was fetched from" };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM || bipM[1] !== doc.paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message does not match the declared payment code" };
|
||||
}
|
||||
// Accept either derivation of the notification address.
|
||||
//
|
||||
// A PayNym is a mainnet identity, but a wallet running on testnet derives the
|
||||
// notification address for THAT network, so the same payment code signs from
|
||||
// a different address depending on which mode the operator's wallet is in.
|
||||
// Insisting on the mainnet form refused perfectly good bindings from anyone
|
||||
// running a testnet wallet — the same defect fixed for listing signatures,
|
||||
// which this path missed.
|
||||
const accept = notificationAddresses(doc.paymentCode);
|
||||
const signer = addrM[1].trim();
|
||||
if (!accept.includes(signer)) {
|
||||
// Naming the addresses matters: the usual cause is signing from a different
|
||||
// account than the payment code entered, and the operator can only spot
|
||||
// that if they can see which address their wallet actually used.
|
||||
const expected = accept.length > 1
|
||||
? `${accept[0]} on mainnet, or ${accept[1]} from a testnet wallet`
|
||||
: accept[0] || "(the code could not be decoded)";
|
||||
return { ok: false, error: `signed by ${signer}, but the payment code's notification address is ${expected} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin; // the message prefix is the same on both
|
||||
try {
|
||||
if (!message.verify(signedMessage, signer, sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) { return { ok: false, error: "signature could not be verified (" + e.message + ")" }; }
|
||||
return { ok: true, address: signer };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — diagnose signed-block mismatches.
|
||||
//
|
||||
// READ-ONLY. For every record whose stored block does not pass the gate, this
|
||||
// answers the question the audit cannot: is the SIGNATURE bad, or is the stored
|
||||
// payload merely a different representation of the same signed text?
|
||||
//
|
||||
// For each record it reports, in order:
|
||||
// 1. INTERNAL VALIDITY - does the signature verify over the block's own text,
|
||||
// and does the BIP47 code inside that text derive the signing address?
|
||||
// If yes, the block is a genuine wallet export and nothing is forged.
|
||||
// 2. MESSAGE MATCH - does the pairing JSON inside the block equal
|
||||
// canonicalPairing(stored payload) byte for byte? If not, it shows the
|
||||
// first differing offset with a window either side, and whether the two
|
||||
// are the same DATA in a different serialisation (key order, spacing) or
|
||||
// genuinely different values.
|
||||
//
|
||||
// Run on the box: cd /var/www/dojobay/server && node diagnose-signed.mjs
|
||||
// Add --all to include records that already pass.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
// canonicalPairing is imported, never reimplemented: a diagnostic that computes
|
||||
// the canonical message its own way can only ever report on itself.
|
||||
// server/selftest.mjs enforces the single definition.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
const ALL = process.argv.includes("--all");
|
||||
const netOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Same data, different serialisation? Compare parsed structures, not strings.
|
||||
const deepEq = (a, b) => {
|
||||
try { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); } catch { return false; }
|
||||
};
|
||||
const sortDeep = (v) => {
|
||||
if (Array.isArray(v)) return v.map(sortDeep);
|
||||
if (v && typeof v === "object") {
|
||||
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])]));
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
function firstDiff(a, b) {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
|
||||
return a.length === b.length ? -1 : n;
|
||||
}
|
||||
const window_ = (s, i) => JSON.stringify(s.slice(Math.max(0, i - 30), i + 30));
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
let genuine = 0, forged = 0, drifted = 0, reorder = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
if (!rec.signed) continue;
|
||||
const net = netOf(rec);
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
const canon = canonicalPairing(rec.payload);
|
||||
const passes = codes.some((c) => {
|
||||
try { return verifySignedPayload({ signedText: rec.signed, expectedMessage: canon, expectedAddress: notificationAddresses(c), network: net }).ok; }
|
||||
catch { return false; }
|
||||
});
|
||||
if (passes && !ALL) continue;
|
||||
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(`\n=== ${label} (${rec.status})${passes ? " [currently passes]" : ""}`);
|
||||
const p = parseSignedBlock(rec.signed);
|
||||
if (!p) { console.log(" block does not parse at all"); continue; }
|
||||
|
||||
// 1. internal validity
|
||||
let sigOk = false;
|
||||
try { sigOk = message.verify(p.message, p.address, p.signature, bip47utils.networks[net].messagePrefix); } catch (e) { console.log(" verify threw:", e.message); }
|
||||
// A PayNym signs from its mainnet notification address whatever network the
|
||||
// node is on, so both derivations are legitimate.
|
||||
let derived = null;
|
||||
try { derived = p.paymentCode ? notificationAddresses(p.paymentCode) : null; } catch { derived = null; }
|
||||
const bound = Array.isArray(derived) && derived.includes(p.address);
|
||||
const derivedTxt = Array.isArray(derived) ? derived.join(" / ") : "(undecodable)";
|
||||
console.log(` signature over the block's own text : ${sigOk ? "VALID" : "INVALID"}`);
|
||||
console.log(` signing address : ${p.address}`);
|
||||
console.log(` BIP47 code inside the signed text : ${p.paymentCode ? p.paymentCode.slice(0, 12) + "…" : "(none)"} -> ${p.paymentCode ? derivedTxt : "n/a"} ${p.paymentCode ? (bound ? "(binds)" : "(DOES NOT BIND)") : ""}`);
|
||||
console.log(` record's payment code(s) : ${codes.map((c) => c.slice(0, 12) + "…").join(", ") || "(none)"}`);
|
||||
if (sigOk && bound) genuine++; else forged++;
|
||||
|
||||
// 2. message match
|
||||
if (p.pairingText === canon) { console.log(" pairing text matches the stored payload exactly"); continue; }
|
||||
const i = firstDiff(p.pairingText, canon);
|
||||
let signedObj, storedObj;
|
||||
try { signedObj = JSON.parse(p.pairingText); } catch {}
|
||||
try { storedObj = JSON.parse(canon); } catch {}
|
||||
const same = signedObj && storedObj && deepEq(signedObj, storedObj);
|
||||
if (same) reorder++; else drifted++;
|
||||
console.log(` pairing text DIFFERS from the stored payload`);
|
||||
console.log(` same data, different serialisation? ${same ? "YES - key order/spacing only" : "NO - the values themselves differ"}`);
|
||||
console.log(` lengths: signed ${p.pairingText.length}, stored ${canon.length}; first difference at offset ${i}`);
|
||||
console.log(` signed: ${window_(p.pairingText, i)}`);
|
||||
console.log(` stored: ${window_(canon, i)}`);
|
||||
if (!same && signedObj && storedObj) {
|
||||
const keys = new Set([...Object.keys(signedObj), ...Object.keys(storedObj)]);
|
||||
for (const k of keys) {
|
||||
if (JSON.stringify(sortDeep(signedObj[k])) !== JSON.stringify(sortDeep(storedObj[k]))) {
|
||||
console.log(` top-level key "${k}" differs:`);
|
||||
console.log(` signed: ${JSON.stringify(signedObj[k])}`);
|
||||
console.log(` stored: ${JSON.stringify(storedObj[k])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary of blocks examined: ${genuine} genuine (valid signature, code binds), ${forged} not genuine.`);
|
||||
console.log(`Mismatches: ${reorder} serialisation-only, ${drifted} with genuinely different values.`);
|
||||
console.log(genuine && !forged
|
||||
? "\nEvery block examined is a real wallet export; the failures are a stored-payload representation problem, not a trust problem."
|
||||
: "");
|
||||
@@ -0,0 +1,182 @@
|
||||
// =============================================================================
|
||||
// TXT record lookups over Tor, for verified operator domains.
|
||||
//
|
||||
// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS
|
||||
// interface resolves names but cannot fetch arbitrary record types. So we ask
|
||||
// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS
|
||||
// proxy the probes use.
|
||||
//
|
||||
// Two deliberate choices, because a resolver's answer decides whether a
|
||||
// verified badge appears and a lying resolver could mint one:
|
||||
//
|
||||
// 1. Several independent resolvers are queried and a fixed number must agree
|
||||
// before a domain is treated as verified (DOH_AGREEMENT, default 2).
|
||||
// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as
|
||||
// a failure, so a Tor hiccup cannot strip a badge from an honest operator.
|
||||
//
|
||||
// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose:
|
||||
// that module is on the self-update path, which has never been exercised on real
|
||||
// hardware, and refactoring it to share code is not a risk worth taking for a
|
||||
// feature that only reads DNS.
|
||||
// =============================================================================
|
||||
import tls from "node:tls";
|
||||
import { socks5Connect } from "../scripts/update.mjs";
|
||||
import type { ProbeCfg } from "../types.js";
|
||||
|
||||
/** Transport settings a lookup needs; the caller may supply a subset. */
|
||||
type LookupCfg = Partial<ProbeCfg> & {
|
||||
/**
|
||||
* An extra certificate authority to trust for this lookup, and nothing else.
|
||||
*
|
||||
* Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON
|
||||
* — against a mock resolver holding a self-signed certificate, WITHOUT
|
||||
* reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off
|
||||
* for the entire process and every other connection made while it is set.
|
||||
* Certificate validation stays on here; the test simply supplies the anchor
|
||||
* that makes its own certificate valid.
|
||||
*/
|
||||
tlsCa?: string | Buffer | Array<string | Buffer>;
|
||||
};
|
||||
|
||||
interface ResolverAnswer { host: string; records: string[] }
|
||||
export interface TxtLookup {
|
||||
records: string[];
|
||||
answered: number;
|
||||
byResolver: ResolverAnswer[];
|
||||
errors: string[];
|
||||
}
|
||||
export interface TxtAgreement {
|
||||
ok: boolean;
|
||||
/** True when too few resolvers replied to draw any conclusion. */
|
||||
inconclusive: boolean;
|
||||
/** Absent when the lookup threw before any resolver could be counted. */
|
||||
answered?: number;
|
||||
agreed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Resolvers use different JSON paths but the same response shape.
|
||||
const RESOLVERS: { host: string; path: string }[] = [
|
||||
{ host: "cloudflare-dns.com", path: "/dns-query" },
|
||||
{ host: "dns.quad9.net", path: "/dns-query" },
|
||||
{ host: "dns.google", path: "/resolve" },
|
||||
];
|
||||
|
||||
export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2));
|
||||
const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read
|
||||
|
||||
function resolverList(): { host: string; path: string }[] {
|
||||
const only = (process.env.DOH_RESOLVERS || "").trim();
|
||||
if (!only) return RESOLVERS;
|
||||
const wanted = only.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return RESOLVERS.filter((r) => wanted.includes(r.host));
|
||||
}
|
||||
|
||||
// One HTTPS GET through the Tor SOCKS proxy, returning the response body as
|
||||
// text. Deliberately minimal: no redirects (a resolver that redirects is not
|
||||
// one we want), and a hard body cap.
|
||||
async function httpsGetOverTor(host: string, path: string,
|
||||
{ proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise<string> {
|
||||
const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } };
|
||||
const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs);
|
||||
const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => {
|
||||
socket.write(
|
||||
`GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` +
|
||||
`Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
socket.on("data", (d: Buffer) => {
|
||||
size += d.length;
|
||||
if (size > MAX_BODY) return finish(reject, new Error("response too large"));
|
||||
chunks.push(d);
|
||||
});
|
||||
socket.on("error", (e: Error) => finish(reject, e));
|
||||
socket.on("close", () => {
|
||||
if (done) return;
|
||||
try {
|
||||
const all = Buffer.concat(chunks);
|
||||
const headEnd = all.indexOf("\r\n\r\n");
|
||||
if (headEnd < 0) return finish(reject, new Error("malformed reply"));
|
||||
const headText = all.subarray(0, headEnd).toString("latin1");
|
||||
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
if (!m) return finish(reject, new Error("malformed reply"));
|
||||
if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1]));
|
||||
let body = all.subarray(headEnd + 4);
|
||||
if (/transfer-encoding:\s*chunked/i.test(headText)) {
|
||||
const parts: Buffer[] = []; let p = 0;
|
||||
for (;;) {
|
||||
const nl = body.indexOf("\r\n", p);
|
||||
if (nl < 0) break;
|
||||
const n = parseInt(body.subarray(p, nl).toString("latin1"), 16);
|
||||
if (!n) break;
|
||||
parts.push(body.subarray(nl + 2, nl + 2 + n));
|
||||
p = nl + 2 + n + 2;
|
||||
}
|
||||
body = Buffer.concat(parts);
|
||||
}
|
||||
finish(resolve, body.toString("utf8"));
|
||||
} catch (e) { finish(reject, e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A DoH JSON answer gives TXT data as a quoted string, and a long record as
|
||||
// several quoted strings that must be concatenated. Normalise both to one line.
|
||||
export function parseTxtAnswer(json: string): string[] | null {
|
||||
let doc: any;
|
||||
try { doc = JSON.parse(json); } catch { return null; }
|
||||
if (typeof doc !== "object" || doc === null) return null;
|
||||
if (doc.Status === 3) return []; // NXDOMAIN: no records
|
||||
if (doc.Status !== 0) return null; // SERVFAIL etc: no answer
|
||||
const answers = Array.isArray(doc.Answer) ? doc.Answer : [];
|
||||
return answers
|
||||
.filter((a: any) => a && (a.type === 16 || a.type === undefined))
|
||||
.map((a: any) => String(a.data || ""))
|
||||
.map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d))
|
||||
.map((d: string) => d.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Look up TXT records for `name` across the resolvers. Returns
|
||||
// { records, answered, byResolver, errors } where `records` is the set of
|
||||
// records seen and `answered` counts resolvers that gave a usable answer.
|
||||
export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise<TxtLookup> {
|
||||
const resolvers = resolverList();
|
||||
const q = `?name=${encodeURIComponent(name)}&type=TXT`;
|
||||
const results = await Promise.allSettled(resolvers.map(async (r) => {
|
||||
const body = await httpsGetOverTor(r.host, r.path + q, cfg);
|
||||
const recs = parseTxtAnswer(body);
|
||||
if (recs === null) throw new Error("resolver returned no usable answer");
|
||||
return { host: r.host, records: recs };
|
||||
}));
|
||||
const byResolver: ResolverAnswer[] = [];
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]; // a local, so the union narrows
|
||||
if (r.status === "fulfilled") byResolver.push(r.value);
|
||||
else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`);
|
||||
}
|
||||
const records = [...new Set(byResolver.flatMap((r) => r.records))];
|
||||
return { records, answered: byResolver.length, byResolver, errors };
|
||||
}
|
||||
|
||||
// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`?
|
||||
// Distinguishes "not there" from "we could not tell".
|
||||
export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean,
|
||||
cfg: LookupCfg = {}): Promise<TxtAgreement> {
|
||||
const { answered, byResolver, errors, records } = await lookupTxt(name, cfg);
|
||||
if (answered < DOH_AGREEMENT) {
|
||||
return { ok: false, inconclusive: true, answered, agreed: 0,
|
||||
error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` };
|
||||
}
|
||||
const agreed = byResolver.filter((r) => r.records.some(predicate)).length;
|
||||
if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed };
|
||||
return { ok: false, inconclusive: false, answered, agreed,
|
||||
error: records.length
|
||||
? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching`
|
||||
: `no TXT record found at ${name}` };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user