Compare commits

..
Author SHA1 Message Date
ssmithx 2d41b082d7 feat(dojobay): add DojoBay app (manifest, image, catalog, ports) 2026-09-11 02:11:42 +00:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
archipelago 5da91e4099 chore: prepare release v1.8.11-alpha 2026-09-07 04:32:02 -04:00
archipelago 62731cc729 test(ui): use shipped app for generated launch port check 2026-09-07 03:30:29 -04:00
archipelago 5e17ace690 style(openwrt): format TollGate installer 2026-09-07 03:26:31 -04:00
archipelago b010471a4a chore(release): prep v1.8.11 notes and link checks 2026-09-07 03:26:01 -04:00
ai c4ede96517 Merge PR #154: docs(openwrt): OpenWrt Gateway setup guide + live-tested fixes
Demo images / Build & push demo images (push) Successful in 3m52s
2026-09-07 07:24:33 +00:00
ai be06e1a502 Merge PR #153: fix(cuprate): enable fast_sync and raise DB cache 2026-09-07 07:24:25 +00:00
ssmithxandClaude Sonnet 5 094f42312c docs(openwrt): document the confirmed working end-to-end install flow
Adds a verification checklist (service running, nodogsplash bound to
br-tollgate not br-lan via the rendered config not just UCI, LAN/SSH
untouched, mint probes succeeding) plus notes on the dev-build test-mint
injection and the default-route race between a router's LAN interface
and the node's other uplinks before the router's own WAN/WISP is live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 03:06:58 +00:00
ssmithxandClaude Sonnet 5 da8c3ec193 docs(openwrt): note the Ctrl+T/LuCI workaround for setting the initial root password
Archipelago's Connect form only authenticates with an existing password;
it has no flow for setting one on a fresh, passwordless router. On the
node's kiosk display there's no visible tab bar, so Ctrl+T to open a new
tab to LuCI is the way to set it before Connect will work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 02:53:21 +00:00
ssmithxandClaude Sonnet 5 4fdf8e8c58 fix(openwrt): bump pinned TollGate release v0.2.0 -> v0.5.0
The install code was hardcoded to the Oct 2025 v0.2.0 release —
nine releases behind. Its changelog covers exactly the failures hit
live against archy-x250-pa3: a mint with an empty/broken keyset
crash-looped tollgate-wrt forever (v0.5.0 adds "graceful degradation
when Cashu mints fail"), and the bundled captive-portal JS had zero
CBOR support, hard-rejecting the cashuB (NUT-00 V4) tokens modern
wallets like Minibits generate by default.

Also: v0.5.0 publishes native .apk packages for aarch64_cortex-a53
and x86_64. install_tollgate_apk_native now prefers those directly
(apk add handles deps/postinst/uci-defaults itself) instead of always
falling back to the manual ar/tar .ipk extraction dance, which only
exists because earlier releases had no native apk build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 17:28:20 +00:00
ssmithxandClaude Sonnet 5 61b5d93b11 docs(openwrt): document the transient post-reboot apk-update failure
Observed live on archy-x250-pa3: right after WAN reconnects (fresh
boot or WAN reconfigure), the first Install attempt can fail with
"apk update failed ... router may have no internet access" purely
because the WiFi-uplink STA association hasn't finished yet — it's
not a real error, just retry a few seconds later. Also cross-referenced
the now-fixed /usr/bin/opkg hardcoding bug for anyone hitting it on an
older build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 16:36:53 +00:00
ssmithxandClaude Sonnet 5 be06b3ce2b fix(ui): stop sending an empty ssh_password over the saved router connection
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.

Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:07 +00:00
ssmithxandClaude Sonnet 5 f3d96ae2ee fix(openwrt): resolve opkg/apk via $PATH, not a hardcoded /usr/bin path
opkg_check() and every opkg/apk invocation hardcoded /usr/bin/opkg and
/usr/bin/apk. Official OpenWrt images don't all symlink /bin into
/usr/bin — the glinet_gl-mt3000 24.10.2 build keeps them as separate
real directories with opkg living in /bin — so the check silently
missed a perfectly normal install and TollGate provisioning failed
with "this router's firmware may not support package management".

Switched every call to resolve through the router's own $PATH
(command -v / bare opkg / apk) instead. Reproduced and fixed live
against archy-x250-pa3, 2026-09-05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:00 +00:00
ssmithxandClaude Sonnet 5 a4ae375617 docs(openwrt): fix TollGate step — install is separate from configure
Step 4 described a single "Provision TollGate" action that prompts for
price/step/mint upfront. The real UI (OpenWrtGateway.vue) doesn't work
that way: "Install TollGate" is a one-click action with no config form
that installs with defaults, and price/step/mint/enabled are only
editable afterward via a separate "Edit" panel. Caught while walking
through a live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:32:39 +00:00
ssmithxandClaude Sonnet 5 0646bc4e85 docs(openwrt): add GL.iNet AX3000 → stock OpenWrt flashing steps
Worked example for the Beryl AX (GL-MT3000, mediatek/filogic) verified
against the OpenWrt wiki and firmware selector: exact sysupgrade image
filename, GL.iNet UI / LuCI flash path, post-flash SSH state, and the
U-Boot recovery procedure if the flash goes sideways.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:26:21 +00:00
ssmithxandClaude Sonnet 5 0faaf4577f docs: add OpenWrt Gateway setup guide
Walks a node operator through pairing an OpenWrt router over SSH,
running the WAN/WISP wizard, and provisioning TollGate pay-as-you-go
WiFi — plus an RPC/architecture reference for developers. Distills
the openwrt crate, RPC handlers, and Vue panel into user-facing steps
that didn't exist anywhere in docs/ before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:07:05 +00:00
ssmithxandClaude Sonnet 5 f9a1ef031c fix(cuprate): front the restricted RPC port with a Tor onion
The restricted-RPC port (18090) was `auth: none`, which the app gate
treats as fully exempt — no onion, no takeover, LAN/Tailscale IP only.
Flip it to `auth: open`: the gate still binds the external addresses
and fronts a Tor onion for the port, just without a dashboard login
challenge, since Monero wallet clients (Feather, monero-wallet-rpc,
GUI) speak plain HTTP JSON-RPC and can't hold a session cookie.

P2P (18183) stays `none` — no reason to Tor-front raw gossip.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it takes effect on any
node, since origin (catalog) wins over disk for catalog-covered apps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZnsiMtyJxiJBuvv7yLPUF
2026-09-03 14:51:24 +00:00
ssmithxandClaude Sonnet 5 cf240df4b6 fix(cuprate): enable fast_sync and raise DB cache — sustained 45% CPU
The default manifest baked in the exact broken config found on an
affected fleet node: no fast_sync (defaults false, forcing full ring-sig/
RandomX verification on every block) and target_max_memory capped at
~2.8GiB, which starved cuprated's DB cache into constant eviction/flush
(595GB/24h of block I/O on a node just appending ~2MB blocks every 2
minutes). A reference node with fast_sync = true and an 8GiB cache ran
at 2.8% CPU at the same chain height and block rate.

Set fast_sync = true and target_max_memory = 8GiB to match the healthy
reference config, and raise resources.memory_limit from 4Gi to 10Gi so
the container still has headroom above the larger cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR7jRaicvqsJaqQQ92jpPQ
2026-09-03 08:56:52 +00:00
archipelago d8320896c4 chore: publish release v1.8.10-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-01 19:01:54 -04:00
archipelago b87f1f0612 chore: prepare release v1.8.10-alpha 2026-09-01 18:58:33 -04:00
archipelago 1ca002661b fix(lnd): SendPaymentV2 needs an explicit fee budget — absent means ZERO
Demo images / Build & push demo images (push) Successful in 3m28s
v1.8.9's move to Router.SendPaymentV2 shipped without fee_limit_sat,
and the v2 route treats an ABSENT fee limit as zero allowed fees.
Every real route carries a routing fee (the 2-hop route here: 1.5
sats), 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 both ways.

The router debug log makes it unambiguous:
  wallet payment (v1.8.9 backend): fee_limit=0 mSAT     -> no route
  same payment by hand (lncli --fee_limit=100): fee_limit=100000 mSAT -> settles in 0.65s

My earlier "pipeline verified" claim was wrong — the manual lncli
verification set a fee limit by hand and masked this exact bug. The
400k that succeeded this morning went through the pre-update backend
on the pre-update LND.

Payments now carry lncli's own default budget — the payment amount
(100%), preferring the payer-supplied amount for zero-value invoices
and the invoice's own amount otherwise, with a nominal floor so the
limit can never be zero. Unit-pinned so it cannot regress.
2026-09-01 18:42:37 -04:00
archipelago 0d0e2e243a feat(lnd): channel-peer watchdog — a dropped peer link heals itself
Demo images / Build & push demo images (push) Successful in 3m49s
LND normally reconnects channel peers after a restart, but not reliably:
after long or repeated downtime (an app update, a node reboot,
reconciler churn) the peer link can stay 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 on framework-pt (2026-09-01): its only channel sat
disabled on both policy sides for ~17 hours after the LND 0.21.2
update, while shorty had 583k spendable and the user was told, by a
mis-mapped modal, that they had 'no payment channel'.

The channel graph is desired state — every open channel should have a
live peer connection. A daemon-side watchdog now enforces it:

- every 2 minutes, list channels + peers over LND REST
- for each channel whose remote peer is not connected, look the peer's
  advertised addresses up in the public graph and dial one
- per-peer retries throttled to 10 minutes so an unreachable peer is
  not hammered; 'already connected' counts as done; a peer with no
  advertised address is logged once per pass (cannot be dialed)
- no-ops quietly on nodes without LND (missing macaroon) and while a
  wallet is locked (503 body has no channels)

Unit tests pin the selection against the live REST shapes
(remote_pubkey in /v1/channels vs pub_key in /v1/peers).

v1.8.10 CHANGELOG + What's New entries staged so the next release run
is clean first time.
2026-09-01 17:51:15 -04:00
archipelago 9c49b502e3 docs: post-1.8.9 verification — pipeline confirmed, routing failure root-caused to framework-pt's disabled channel 2026-09-01 16:36:27 -04:00
archipelago d68a013e35 docs: tracker — v1.8.9 published, NPM live-healed on shorty via the signed catalog; funding-gate fix staged for v1.8.10 2026-09-01 11:43:33 -04:00
archipelago 1464b1b24d fix(wallet): the Lightning funding gate states the node's real channel state
Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.

The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
  confirmations, it unlocks automatically, nothing is needed from you"
  (and no "Open a channel" button — that would send the user to fix
  a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
  side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
  of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance

Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
2026-09-01 11:40:25 -04:00
archipelago 82001403b4 chore: publish release v1.8.9-alpha 2026-09-01 11:05:55 -04:00
archipelago 81ede159ac chore: prepare release v1.8.9-alpha 2026-09-01 11:02:00 -04:00
archipelago 8e988be853 chore(release): v1.8.9-alpha prep — What's New block + version bumps
Demo images / Build & push demo images (push) Successful in 3m48s
The release gate requires the freshly-built bundle to embed the new
version, and the version reaches the bundle through the What's New
modal in AccountInfoSection — there was no v1.8.9-alpha block yet, so
create-release.sh correctly refused to ship a bundle that looked stale.
This adds the block (the user-facing summary of today's LND/HTTPS/
launcher/NPM/Portainer fixes) and carries the version bumps the
aborted run had already written (Cargo.toml, Cargo.lock, package.json,
package-lock), so the re-run starts from a clean tree.

Verified: npm run build now produces assets containing 1.8.9-alpha
(Settings chunk), i.e. the exact check the script runs passes.
2026-09-01 10:54:47 -04:00
archipelago 210f7f1b12 chore(catalog): re-sign the catalog — NPM letsencrypt mount + NET_BIND_SERVICE
Regenerated from the fixed apps/nginx-proxy-manager/manifest.yml (the
only semantic change vs the previous signed catalog) and signed with
the release-root key. Catalog-covered nodes pick this up on their next
hourly fetch and the NPM start/die loop ends: s6 gets its /etc/letsencrypt
mount back and the internal nginx can bind 80/443/81 again under
--cap-drop=ALL.
2026-09-01 10:37:09 -04:00
archipelago ed49cc974f docs: tracker updated — fixes landed, tests green, remaining steps are the two mnemonic ceremonies + node updates 2026-09-01 10:31:36 -04:00
archipelago 4849186ab9 docs: incident tracker for the 2026-09-01 https/launcher/LND breakage + v1.8.9 notes
Demo images / Build & push demo images (push) Successful in 3m57s
Root-cause table, fix inventory, regression-test inventory and the
deploy/live-verification checklist for today's fleet incident — written
as the working record while the fixes land, so the deploy + verify steps
can be checked off against real nodes rather than memory. CHANGELOG
carries the user-facing notes for the release these fixes ship in.
2026-09-01 10:29:10 -04:00
archipelago 3347b8b8b9 fix(ui): https app launches and the nostr bridge follow the frame's real origin
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:

1. portAuth() looked the launch port up under the name the user clicks
   ('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
   declares those ports under the manifest id that owns them
   (archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
   portIsGateFronted answered false, and an HTTPS dashboard handed app
   frames http:// URLs — blocked as mixed content: mempool and IndeeHub
   'did not connect', bitcoin knots/core opened http:// in a new tab.
   Resolution now follows launch aliases, then a port-wide catalog scan
   that only answers when every declarer of that port agrees (a port
   any app publishes as plain HTTP is never upgraded to https).

2. The signed-catalog cache was only warmed by the Store/Discover
   views, so a user who went straight to My Apps launched apps with an
   empty cache. Warmed at dashboard mount now — fetchAppCatalog()
   already memoizes with a 1h TTL.

3. The NIP-07 bridge compared event.origin for strict equality with the
   recorded (http) app URL and replied to the recorded URL as the
   postMessage targetOrigin — both break the moment a frame is scheme-
   upgraded (cached HSTS did exactly that): every nostr request was
   silently dropped and replies to the stale origin threw. The bridge
   now matches host+port (scheme deliberately ignored) and always
   replies to event.origin — the frame's real origin.

Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
2026-09-01 10:29:05 -04:00
archipelago e382e679ae fix(apps): NPM needs /etc/letsencrypt mounted and NET_BIND_SERVICE
Converting Nginx Proxy Manager to a platform manifest (fc68c5b6) dropped
two things its image hard-requires, and the result was an endless
start/die loop — shorty-s watched it restart 3,176 times:

1. /etc/letsencrypt mount: NPM's s6 'prepare' service refuses to boot
   without it ('ERROR: /etc/letsencrypt is not mounted!'). Mounted from
   the same persistent app directory as before
   (/var/lib/archipelago/nginx-proxy-manager/letsencrypt), so existing
   certificates are preserved — no data moves, no migration.

2. NET_BIND_SERVICE: NPM's internal nginx listens on 80, 443 AND 81,
   and the orchestrator runs --cap-drop=ALL. The legacy podman-run path
   defaulted to the full capability set (and the legacy repair path in
   package/config.rs always listed it), which is why this only broke
   once the manifest became the source of truth.

The signed catalog embeds manifests with origin-wins semantics, so the
catalog carries the fix for every catalog-covered node — regenerate it
here (plus the generated store/launcher-port artifacts, which also pick
up drift from bf6ef964's retired apps). Catalog re-signing follows the
usual ceremony.
2026-09-01 10:29:05 -04:00
archipelago 77d0768a21 fix(nginx): stop pinning HSTS — actively clear it instead
The HTTPS server block sent Strict-Transport-Security:
max-age=31536000; includeSubDomains. Browsers that visited HTTPS once
cached the policy and then silently upgraded the still-open HTTP
dashboard's fetches and frames to https — a scheme change is
cross-origin, so every /rpc/v1 call died 'No Access-Control-Allow-
Origin header' while the node was perfectly healthy (framework-pt
2026-09-01: the 'Failed to fetch' storm, dashboard 'not responding',
every app frame mixed-content-blocked).

Plain HTTP is a supported access mode BY DESIGN on this platform: the
node's certificate is optional and self-signed (Settings → Node
certificate, /ca.crt flow), and setup-node-ca.sh deliberately keeps
port 80 serving for devices that haven't installed the CA. So:

- port 80 sends no HSTS at all (with the rationale inline)
- port 443 sends max-age=0, which ACTIVELY DELETES the policy already
  cached by affected browsers — leaving it absent would have kept every
  stranded browser broken for a year

tests/lifecycle/bats/nginx-hsts.bats pins all three properties at the
gate: no live policy on :80, max-age=0 (never 31536000) on :443, and
no long-lived pin anywhere in the deployed config.
2026-09-01 10:28:57 -04:00
archipelago f133d5555a feat(apps): surface Portainer's first-run setup token in the credentials interstitial
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 ONLY to the server logs. On an appliance that is a dead end —
'check the Portainer server logs' is exactly what a user cannot follow,
and after the 2.45.0 update it made a freshly restarted Portainer look
broken ('disappeared', then demands a token nobody can find).

package.credentials — the same RPC that powers the login-credentials
card on the app page — now extracts the setup_token line from
portainer's recent container logs and hands it over with the existing
copy-button treatment, titled and explained for a first-time user. The
token stops being printed once setup completes, and any container
recreate drops the log line, so the card disappears on its own and no
dead token lingers. Parsing is a pure, unit-tested scan against the
live-captured 2.45.0 log shape (64 hex chars after setup_token=).
2026-09-01 10:28:57 -04:00
archipelago cbd5314dd9 fix(lnd): pay through Router.SendPaymentV2 — LND 0.21 removed the old route
LND 0.21.2 removed the deprecated Lightning.SendPaymentSync REST route
(/v1/channels/transactions). The backend still called it, so every
Lightning send answered literal HTTP 404 and the wallet UI reported
'Payment failed: Not Found' fleet-wide right after the pin bump —
receive worked, which made it look intermittent.

Pay through the supported Router.SendPaymentV2 route (/v2/router/send)
instead, keeping the existing contract with the UI:
- single-record responses (no_inflight_updates) unwrapped from the
  grpc-gateway result envelope, transport errors from the nested error
- a slow multi-hop payment still resolves as pending + payment hash
  (only LND may declare failure), never a false 'Payment failed'
- LND's failure_reason codes translated to the same plain-language
  advice, invoice-expiry still says 'ask for a fresh invoice'

Guard it at the gate: tests/lifecycle/bats/lnd-api-compat.bats POSTs a
deliberately-invalid invoice to /v2/router/send on the RUNNING LND and
fails if the route answers 404 — the image/backend skew that shipped
silently last time because no test ever spoke the payment endpoint.
Also bumps the stale lnd image expectation in remote-lifecycle.sh.
2026-09-01 10:28:49 -04:00
archipelago 9fb2e1ed9e chore(catalog): sign the Cuprate logging fix 2026-09-01 08:47:29 -04:00
archipelago 7125dea05d Merge PR #152: fix Cuprate logging defaults 2026-09-01 08:39:26 -04:00
ssmithxandClaude Sonnet 5 bcdf2c75be fix(cuprate): file log level should be info, not cuprated's debug default
The previous commit on this branch copied cuprated's raw
--generate-config defaults (stdout=info, file=debug, max_log_files=7)
verbatim. Turns out that's the wrong reference: compared against
ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml — the actual
dev config this app was built and tested against — file logging is
meant to run at "info" with 14 rotated files, not the binary's raw
"debug"/7. Confirmed live on amishparadise: podman logs (stdout) was
already clean at info, but the on-disk file log
(.local/share/cuprate/logs/<date>) was flooding with per-peer DEBUG
gossip (~400KB in 2 minutes) because it inherited the binary default
instead of the intended one.

Left the resource-tuning knobs in the reference config (8GB
target_max_memory, tokio/rayon thread counts, P2P connection counts,
explicit reader_threads) out of this file — those were sized for
ssmithx's dev box and don't apply here; this manifest's
target_max_memory already stays deliberately under the container's
4Gi memory_limit.

Regenerated releases/app-catalog.json (still unsigned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:27:33 +00:00
ssmithxandClaude Sonnet 5 e77f60085d fix(cuprate): make Cuprated.toml logging levels explicit
apps/cuprate/manifest.yml only ever wrote network/target_max_memory/
rpc.restricted.enable into Cuprated.toml, so the [tracing.stdout] and
[tracing.file] tables were silently absent — cuprated still applied
its built-in info/debug/7 defaults, but nothing on disk showed it.
Verified live on amishparadise 2026-09-01: the deployed 5-line file
had no [tracing] section at all, and the level was only discoverable
by running `cuprated --generate-config` and diffing.

Add both tables to the manifest's files[].content with the same
values cuprated already defaults to, so every new install ships a
Cuprated.toml an operator can actually read and tune. overwrite:false
means already-deployed nodes (amishparadise included) keep their
existing file untouched — this only changes what fresh installs get.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it's authoritative for
the fleet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:19:20 +00:00
archipelago 6c31eb9d4a chore(catalog): sign the LND 0.21.2 sweep 2026-09-01 07:45:39 -04:00
archipelago 63e6c64c63 fix(ci): drop the remaining stray Claude-worktree gitlinks 2026-09-01 05:27:15 -04:00
archipelago 4d8bb1fd44 fix(ci): drop a stray gitlink that broke every demo build
A Claude worktree under aiui/ was committed as a submodule gitlink
(160000) with no .gitmodules entry, so actions/checkout's recursive
submodule pass exited 128 and every 'Build & push demo images' run since
v1.8.6 failed. Removing the index entry — the worktree is local state
and was never meant to be tracked.
2026-09-01 05:27:00 -04:00
archipelago 2b4b60013c feat(lnd): build LND 0.21.2-beta in-house and sweep the pin
Demo images / Build & push demo images (push) Failing after 40s
Upstream publishes no docker images; our v0.18.4 image was built in-house.
This pass: official v0.21.2-beta release binaries (sha256-verified against
the signed release manifest), static, on alpine with the same entrypoint
shape as our existing image, pushed to our registry and smoke-run.
LND 0.21 auto-migrates the channel DB on first start (keeping a backup) —
the Update button is user-initiated, never auto-applied.
2026-09-01 05:14:50 -04:00
archipelago f0ef410948 chore(catalog): sign the swept pins 2026-09-01 04:52:28 -04:00
archipelago 19467e9b7c chore(apps): sweep pin bumps — gitea 1.27.3, vaultwarden 1.37.2, filebrowser 2.63.23, home-assistant 2026.8.3, adguardhome 0.107.79, portainer 2.45.0, pine-whisper 3.6.0
Demo images / Build & push demo images (push) Failing after 39s
First upstream sweep since v1.8.5: the safe patch/minor pins, mirrored
into our registry first (source.archipelago-foundation.org/lfg2025/*).
Held for their own careful passes: the majors (grafana 11, nextcloud,
uptime-kuma 2, bitcoin-core 29, the DBs) and consensus-sensitive apps
(fedimint, electrumx 2.0). LND 0.21.2 needs an in-house image build —
upstream publishes none.
2026-09-01 04:50:49 -04:00
archipelago 628ed252b4 chore: publish release v1.8.8-alpha 2026-09-01 04:18:16 -04:00
archipelago bc94445ca0 chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:57:23 -04:00
archipelago 04cf0f663a chore: drop the superseded v1.8.8 prep for rebuild 2026-09-01 03:49:16 -04:00
archipelago 576c642da4 fix(apps): ollama resource type + adguardhome port; gate on collisions
Demo images / Build & push demo images (push) Failing after 38s
Ollama's embedded manifest failed the typed parse (memory_limit wants a
string) so the catalog overlay was skipped for it; AdGuard Home's
conventional :3000 collided with Grafana's. The release gate now runs
the host-port collision test (repo_app_manifests_have_no_host_port_collisions)
so this class can never ship untested again.
2026-09-01 03:31:02 -04:00
archipelago 12866db84a chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:16:10 -04:00
archipelago a184254706 style: rustfmt the ssh-mesh module 2026-09-01 02:42:39 -04:00
archipelago 192e045426 feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
2026-09-01 02:41:55 -04:00
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00
archipelago bf6ef9644c chore(apps): retire morphos-server, did-wallet, lightning-stack, cryptpad
Store-listing components are filtered via the shared serviceNames canon;
these four never earn a tile: MorphOS server is old, the Web5 DID wallet
and CryptPad are untested, Lightning Stack is an untracked upstream
bundle (LND covers it).
2026-09-01 02:41:55 -04:00
archipelago c32910809e chore: publish release v1.8.7-alpha 2026-09-01 01:42:12 -04:00
archipelago d2174128c5 chore: sign the v1.8.7 app catalog + release manifest 2026-09-01 01:37:24 -04:00
archipelago 2ad0171e5f fix(ui): drop the now-unused scheme helper
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 19:08:17 -04:00
archipelago 46cb0bfd37 fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
2026-08-31 18:41:00 -04:00
archipelago b8593c9090 docs(release): v1.8.7 notes — https app launches + platform round
Demo images / Build & push demo images (push) Failing after 34s
2026-08-31 18:40:51 -04:00
archipelago fc68c5b680 feat(apps): complete the manifest platform — convert the last five stragglers
Demo images / Build & push demo images (push) Failing after 36s
Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home now
carry full manifests: the app gate fronts their web ports (TLS on the
same port, node login where appropriate), installs run through the
orchestrator, and pins live in the signed catalog. Tailscale mirrors its
legacy shape exactly (userspace networking, web console on 8240, plain
HTTP for the gate to front). Ollama stays loopback-only — the
assistant's local model backend, not a web app.

Retires the four already-removed apps for good (FIPS, Nostr VPN,
Routstr, Penpot pins dropped from image-versions.sh, the generator map,
and image_versions.rs), fixes Cuprate's duplicated metadata block that
strict YAML parsers reject, and updates the port-inventory review gates
for the new open (3 own-login consoles) and exempt (2 DNS) ports.
2026-08-31 18:40:39 -04:00
archipelago 3ed75c328d style: rustfmt the signed-catalog serving 2026-08-31 17:09:16 -04:00
archipelago 687196ad3b chore: prepare release v1.8.7-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 17:08:37 -04:00
archipelago e2bd6330a1 test(app-catalog): pin the signed-catalog body gate 2026-08-31 17:08:37 -04:00
archipelago 7c0a492c43 fix(ui): launch apps on the page's scheme over HTTPS
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
2026-08-31 17:08:26 -04:00
archipelago 3089624969 Merge remote-tracking branch 'gitea-vps2/main' 2026-08-31 16:15:17 -04:00
archipelago 5b658cec67 feat(app-catalog): serve the signed catalog from the node first 2026-08-31 16:15:13 -04:00
archipelago 21b8d4b1ee catalog: add Cuprate (0.1.0-preview) 2026-08-31 16:10:38 -04:00
lfg2025 6f05f5583f Merge pull request 'docs: session record — companion 0.5.28 shipped + deployment playbook' (#151) from companion/session-2026-08-31 into main 2026-08-31 20:03:03 +00:00
Dorian 02ac4396d1 docs: session record — companion 0.5.28 shipped + the deployment playbook
Full state at session end (all public surfaces verified byte-identical
at 0.5.28/vc48; only node web-bundle redeploys outstanding), the feature
map, and the operational playbook next sessions need: Tor SOCKS proxy
for Gitea API/curl (the 'unreachable API' was a missing proxy flag),
token scopes, protected-main ship flow via -ship branch + PR + API merge,
stale local main lineage, the foundation server's two surfaces, demo CI
auto-redeploy, build/test commands, and the open items.
2026-08-31 21:02:59 +01:00
archipelago 5ffdcc9936 docs(release): explain the v1.8.7 correction
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:46:18 -04:00
archipelago 9cf07e1eac fix(release): enforce the v1.8 What's New floor
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:44:48 -04:00
archipelago e7854702c0 chore: publish release v1.8.6-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:40:23 -04:00
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
Dorian dd07da53f9 chore(android): update companion apk download 2026-08-31 19:34:57 +01:00
Dorian 7d09418a09 Companion 0.5.28 — backup & restore (#128), NIP-46 remote signer (#139), companion-gated install pitch (#61 residual)
The companion-agent queue from the 2026-08-30 handoff, complete:

- Backup & Restore: hub sub-page, SAF export/import sealed in the
  node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
  with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
  nostrconnect:// QR pairing (scanner + OS deep link), per-request
  approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
  wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
  official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
  isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
  display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
  pure public-key function — verified against the fips crate).

Issues #61 (comment), #128, #139 closed on the tracker.
2026-08-31 19:34:05 +01:00
archipelago eef35d65b7 chore: release v1.8.5-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 14:27:00 -04:00
archipelago 3b3500a7dd test(image): gate installer crash-capture payload 2026-08-31 12:38:59 -04:00
archipelago 2f0f7fd388 fix(host): repair malformed legacy kdump defaults 2026-08-31 11:16:34 -04:00
archipelago b300a720db fix(host): query package allowlist without literal quotes 2026-08-31 10:44:15 -04:00
archipelago 5b6d278c46 fix(host): preserve shell variables in privileged fixups 2026-08-31 10:24:06 -04:00
archipelago 699669a5f7 fix(host): retain captured kdump vmcores 2026-08-31 09:57:09 -04:00
archipelago 54431fc856 fix(host): enforce the full kdump crash reservation 2026-08-31 09:16:18 -04:00
archipelago 3409db569e docs(release): complete the v1.7.44→current release-notes audit
Demo images / Build & push demo images (push) Failing after 37s
The RELEASE_NOTES_BACKLOG gate for cutting the next release, closed out:

- Eight sections backfilled to the curated standard, from the Settings
  What's New blocks, the old-lineage release commits, and the hotfix
  diffs: v1.7.44 (was four raw commit-hash lines), v1.7.47/48/64/65
  (thin), and v1.7.50/51/107 (real tagged releases whose sections were
  missing entirely — v1.7.107 restored verbatim from the curated copy
  at 35e9c624 that later went missing).
- Mechanical inventory across all 92 sections in range: every section
  now has ≥3 curated bullets, zero raw-hash entries.
- What's New modal regenerated for the three restored versions
  (sync-whats-new --check passes, 92 versions present).
- Manifest-notes-only confirmed by construction: the manifest reads its
  changelog from CHANGELOG.md and check-release-manifest.sh rejects raw
  or thin entries before publishing.

Evidence trail for the backfills is recorded in
docs/RELEASE_NOTES_BACKLOG.md.
2026-08-31 08:05:42 -04:00
archipelago cb71c25ea0 chore(catalog): carry the Cuprate store entry into the frontend public catalog
Demo images / Build & push demo images (push) Failing after 39s
generate-app-catalog.py only updates entries that already exist in each
catalog file, so the hand-curated cuprate entry (added to
app-catalog/catalog.json with 7b88ba59) never propagated to
neode-ui/public/catalog.json — the sync's field-bumps did, the new entry
did not. Both catalogs now carry identical 31-entry lists (verified
content-equal), so the browser-side store copy and the curated one agree.
2026-08-31 07:48:31 -04:00
archipelago c5eeb31055 fix(ui): wifi setup on a fresh install — reveal toggle + a no-network callout (#145)
Demo images / Build & push demo images (push) Failing after 39s
Two reports from a fresh install without a cable:

(a) No way to see the WiFi password being typed. Every password field in
    the app was a bare type=password input. PasswordRevealInput is the
    reusable fix — masked by default, one-tap eye toggle, v-model and
    enter pass-through — first applied to the WiFi prompt in ServerModals
    so a long key typed from across the room can be verified.

(b) WiFi settings are undiscoverable with no wired internet. New
    OnboardingNetworkCallout floats over every onboarding step when the
    node has NO physical link at all (no ethernet up, no WiFi associated
    — polled from network.list-interfaces, self-dismissing the moment a
    link exists) and deep-links 'Connect to WiFi' to
    /dashboard/server?open=wifi, which Server.vue consumes by popping the
    WiFi picker on arrival. Deliberately scoped the other way too:
    Archipelago is offline-first, so 'no internet' never nags — only 'no
    link at all', only during onboarding (the wrapper hosts /login too;
    the callout is restricted to /onboarding/* routes), and a failed probe
    stays silent. The query is consumed via history.replaceState so a
    KeepAlive tab-return never re-pops the modal, and Server.vue keeps
    reading it from the real URL rather than vue-router — its
    KeepAlive-mounted tests have no router context to give.

Verification: full frontend suite 1023/1023; type-check clean; production
build clean with both new strings confirmed in the emitted bundles
(OnboardingWrapper + Server chunks).
2026-08-31 07:47:47 -04:00
181 changed files with 19951 additions and 7894 deletions
+91 -5
View File
@@ -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.
@@ -309,6 +373,12 @@
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.107-alpha (2026-07-20)
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
@@ -688,11 +758,13 @@
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
## v1.7.64-alpha (2026-05-18)
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
## v1.7.63-alpha (2026-05-18)
@@ -802,6 +874,18 @@
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
## v1.7.51-alpha (2026-04-30)
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
## v1.7.50-alpha (2026-04-30)
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
@@ -813,12 +897,15 @@
## v1.7.48-alpha (2026-04-29)
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
## v1.7.47-alpha (2026-04-29)
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
## v1.7.46-alpha (2026-04-29)
@@ -841,10 +928,9 @@
## v1.7.44-alpha (2026-04-28)
43de3b73 feat(orchestrator): complete container migration and release hardening
ce39430b feat(self-update): sync and rebuild UI containers on OTA
72dec5aa fix(lnd-ui): align container port across all specs
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
All notable changes to Archipelago will be documented in this file.
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
View File
@@ -11,16 +11,47 @@
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.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"
}
]
}
+1
View File
@@ -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)
+91
View File
@@ -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
View File
@@ -15,11 +15,6 @@ app:
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
metadata:
icon: /assets/img/app-icons/cuprate.svg
repo: https://github.com/Cuprate/cuprate
tier: optional
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
@@ -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:
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-39
View File
@@ -1,39 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
-59
View File
@@ -1,59 +0,0 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+106
View File
@@ -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
+2 -2
View File
@@ -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"]
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
# Lightning Stack - uses official image
FROM lightninglabs/lightning-stack:v0.12.0
# Default configuration is in the image
# No additional setup needed
-85
View File
@@ -1,85 +0,0 @@
app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
image: lightninglabs/lightning-stack:v0.12.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 50Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 50Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: lightning-stack
ports:
- host: 9738
container: 9735
protocol: tcp # P2P
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10010
container: 10009
protocol: tcp # gRPC
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
source: /var/lib/archipelago/lightning-stack
target: /root/.lightning
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+2 -2
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-37
View File
@@ -1,37 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV MORPHOS_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
-55
View File
@@ -1,55 +0,0 @@
app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
image: archipelago/morphos-server:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: morphos-server
ports:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/morphos-server
target: /app/data
options: [rw]
environment:
- MORPHOS_ENV=production
- MORPHOS_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "morphos-server",
"version": "1.0.0",
"description": "MorphOS server platform",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-27
View File
@@ -1,27 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'MorphOS Server',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`MorphOS Server listening on port ${port}`);
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+88
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
app:
id: ollama
name: Ollama
version: 0.5.4
upstream:
kind: github
repo: ollama/ollama
description: >-
Run large language models locally. Download and run AI models like
Llama, Mistral on your own hardware — served on the node's loopback for
the AI assistant (Settings → Claude Auth → model backend), never exposed
to the network.
container:
image: source.archipelago-foundation.org/lfg2025/ollama:latest
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 50Gi
resources:
# No memory limit: models are sized by the disk allowance below, and a
# RAM ceiling would just OOM-kill long inferences.
disk_limit: 50Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 11434
container: 11434
protocol: tcp
# local: Ollama's REST API is consumed by the node's own assistant over
# loopback — never externally reachable, so no gate, no TLS, and no
# login surface exist at all.
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/ollama
target: /root/.ollama
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:11434
interval: 30s
timeout: 5s
retries: 3
metadata:
author: Ollama
category: community
icon: /assets/img/app-icons/ollama.png
repo: https://github.com/ollama/ollama
tier: optional
+2 -2
View File
@@ -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]
+2 -2
View File
@@ -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"
+78
View File
@@ -0,0 +1,78 @@
app:
id: tailscale
name: Tailscale
version: 1.78.0
upstream:
kind: github
repo: tailscale/tailscale
description: Zero-config VPN with WireGuard mesh networking.
container:
image: source.archipelago-foundation.org/lfg2025/tailscale:stable
pull_policy: if-not-present
network: pasta
# Mirrors the legacy curated install exactly: tailscaled in userspace
# networking (no host TUN device needed — the rootless container cannot
# have one anyway), then `tailscale web` serving the console on :8240 as
# plain HTTP the app gate can front (TLS on the same port via the node
# certificate, framing-header fixes, retry page, Tor).
entrypoint: ["sh", "-c", "tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8240
container: 8240
protocol: tcp
bind: 127.0.0.1
# open, not gated: the web console requires the tailnet's own login for
# every administrative action — the gate fronts the port without adding
# a second login in front of it.
auth: open
auth_rationale: >-
Tailscale's web console authenticates against the tailnet account for
all administrative actions; the node's cookie challenge would be a
second, redundant login.
volumes:
- type: bind
source: /var/lib/archipelago/tailscale
target: /var/lib/tailscale
options: [rw]
environment:
- TS_STATE_DIR=/var/lib/tailscale
health_check:
type: tcp
endpoint: localhost:8240
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web console
description: Tailscale web console
type: ui
port: 8240
protocol: http
path: /
metadata:
author: Tailscale
category: networking
icon: /assets/img/app-icons/tailscale.webp
repo: https://github.com/tailscale/tailscale
tier: recommended
+2 -2
View File
@@ -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
+22
View File
@@ -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
View File
@@ -0,0 +1,588 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "source.archipelago-foundation.org/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
"headline": "Stream Sovereignty",
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4.0",
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights",
"title": "BotFights",
"version": "1.2.11",
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"version": "0.10.0",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
"id": "tailscale",
"title": "Tailscale",
"version": "1.78.0",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
"version": "1.23.0",
"description": "Self-hosted uptime monitoring.",
"icon": "/assets/img/app-icons/uptime-kuma.webp",
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.8.11-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.8.11-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+15
View File
@@ -145,6 +145,21 @@ impl ApiHandler {
/// URL so the App Store still renders on nodes that haven't persisted
/// a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
// The daemon already refreshes and verifies releases/app-catalog.json.
// Serve that release-root-anchored cache first so a newly published app
// appears immediately, without a frontend release. The old external UI
// catalog below is emergency compatibility only; it must never override
// a healthy signed catalog (Cuprate was invisible for exactly that reason).
if let Ok(body) =
crate::container::app_catalog::verified_catalog_body(&self.config.data_dir).await
{
return Ok(Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-cache")
.body(hyper::Body::from(body))?);
}
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
@@ -558,6 +558,11 @@ impl RpcHandler {
self.handle_fips_remove_seed_anchor(&p).await
}
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
"fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await,
"fips.ssh-over-mesh.set" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_ssh_over_mesh_set(&p).await
}
// System updates
"update.check" => self.handle_update_check().await,
+47
View File
@@ -261,4 +261,51 @@ impl RpcHandler {
}).collect::<Vec<_>>(),
}))
}
/// The SSH-over-mesh toggle state plus sshd preflights (the card explains
/// the rule instead of gating on it — see ssh_mesh.rs).
pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result<serde_json::Value> {
let state = fips::ssh_mesh::load(&self.config.data_dir).await;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"preflights": preflights,
}))
}
/// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` —
/// an empty/absent source list opens port 22 to every mesh peer (the UI
/// confirms that explicitly before calling with it).
pub(super) async fn handle_fips_ssh_over_mesh_set(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?;
let sources: Vec<String> = params
.get("sources")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (state, outcome) =
fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"applied": outcome.applied,
"removed": outcome.removed,
"reloaded": outcome.reloaded,
"preflights": preflights,
}))
}
}
+152 -49
View File
@@ -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);
}
}
+1 -1
View File
@@ -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"),
+217
View File
@@ -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());
}
}
+37 -3
View File
@@ -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,
];
+8
View File
@@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
}
}
// SSH-over-mesh rides every config install so the on-state survives
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
// this module owns the 90-ssh.nft slot exclusively).
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
+1
View File
@@ -32,6 +32,7 @@ pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod ssh_mesh;
pub mod telemetry;
pub mod update;
+492
View File
@@ -0,0 +1,492 @@
//! SSH over the FIPS mesh — a first-class settings toggle.
//!
//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/
//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins
//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct
//! by default — but the user asked to be able to SSH their node from Termux
//! over the phone's FIPS mesh instead of keeping a second VPN around for it,
//! and the mesh path already works end-to-end (verified live: the connect
//! reaches fips0 and gets a RST from the node).
//!
//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly
//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this
//! feature can never fight over the same slot:
//!
//! * toggle OFF → drop-in removed, port 22 refused again
//! * toggle ON → drop-in written on every toggle change AND on every
//! daemon config install (upgrade, reconnect, self-heal),
//! so the on-state survives reinstalls idempotently
//! * scope → "any" (every mesh peer — a real exposure, gated in the
//! UI behind an explicit confirmation) or an explicit list
//! of mesh addresses
//!
//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to
//! `config.rs`, and the sshd process itself is entirely the operator's.
use std::net::Ipv6Addr;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
/// On-disk state under the archipelago data dir. Absent file = disabled,
/// which is the safe default for every node that never touched the toggle.
const STATE_FILE: &str = "fips-ssh-over-mesh.json";
/// The drop-in slot this module owns. 90 sorts after the daemon's own
/// drop-ins (80/85) so a human reading the directory sees the deliberate
/// order; the include order does not change semantics for plain accepts.
pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft";
/// The hardening baseline this drop-in hangs off. Same file `config.rs`
/// reloads after its own drop-ins.
const FIPS_NFT: &str = "/etc/fips/fips.nft";
/// Persisted toggle state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SshMeshState {
/// Whether port 22 is allowed through the fips0 baseline at all.
#[serde(default)]
pub enabled: bool,
/// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh
/// peer. Kept as strings as-entered but validated as IPv6 on save.
#[serde(default)]
pub sources: Vec<String>,
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(STATE_FILE)
}
/// Load the persisted state. Missing file = disabled, no sources — never an
/// error, so a fresh node and a deleted file both mean "off".
pub async fn load(data_dir: &Path) -> SshMeshState {
match tokio::fs::read_to_string(state_path(data_dir)).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SshMeshState::default(),
}
}
/// Validate and normalise an operator-supplied source list. Every entry must
/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) —
/// anything else is refused with the offending entry named, so a typo can
/// never silently narrow or widen the rule.
pub fn validate_sources(raw: &[String]) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(raw.len());
for entry in raw {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let addr: Ipv6Addr = trimmed
.parse()
.with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?;
out.push(addr.to_string());
}
out.dedup();
Ok(out)
}
/// Render the nft drop-in for a state. The rule shape mirrors the interim
/// manual unblock from the field notes (`ip6 saddr <ula> tcp dport 22
/// accept`) — an unrestricted rule is the same statement without the saddr.
pub fn render_dropin(state: &SshMeshState) -> String {
let mut out = String::from(
"# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\
# Allows sshd (port 22) through the fips0 default-deny inbound\n\
# baseline. Remove = refused again; never edit 80/85-* by hand.\n",
);
if state.sources.is_empty() {
out.push_str("tcp dport 22 accept\n");
} else {
out.push_str(&format!(
"ip6 saddr {{ {} }} tcp dport 22 accept\n",
state.sources.join(", ")
));
}
out
}
/// Write or remove the drop-in to match the persisted state, then reload the
/// baseline so the change is live immediately. Returns whether a reload was
/// attempted and succeeded — a node without the hardening baseline has
/// nothing to reload (port 22 is governed by sshd and the host firewall
/// there), which is reported rather than treated as failure.
pub async fn reconcile(data_dir: &Path) -> Result<ReconcileOutcome> {
let state = load(data_dir).await;
if !state.enabled {
let removed = remove_dropin().await?;
let reloaded = reload_nft().await;
return Ok(ReconcileOutcome {
applied: false,
removed,
reloaded,
});
}
// Ensure /etc/fips/fips.d exists, exactly like config::install.
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"])
.output()
.await
.context("sudo install -d /etc/fips/fips.d")?;
if !out.status.success() {
anyhow::bail!(
"sudo install -d /etc/fips/fips.d failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let dropin = render_dropin(&state);
let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id()));
tokio::fs::write(&stage, &dropin)
.await
.context("stage ssh nft drop-in")?;
let install = Command::new("sudo")
.args(["install", "-m", "0644"])
.arg(&stage)
.arg(DROPIN_PATH)
.output()
.await;
let _ = tokio::fs::remove_file(&stage).await;
let install = install?;
if !install.status.success() {
anyhow::bail!(
"install {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&install.stderr).trim()
);
}
let reloaded = reload_nft().await;
Ok(ReconcileOutcome {
applied: true,
removed: false,
reloaded,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileOutcome {
/// The allow rule is in place.
pub applied: bool,
/// A previously-written drop-in was removed this call.
pub removed: bool,
/// The hardening baseline existed and `nft -f` succeeded.
pub reloaded: bool,
}
async fn remove_dropin() -> Result<bool> {
match tokio::fs::try_exists(DROPIN_PATH).await {
Ok(true) => {}
_ => return Ok(false),
}
let out = Command::new("sudo")
.args(["rm", "-f", DROPIN_PATH])
.output()
.await
.context("sudo rm 90-ssh.nft")?;
if !out.status.success() {
anyhow::bail!(
"removing {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again");
Ok(true)
}
/// Reload the hardening baseline. Best-effort in the same spirit as
/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed
/// reload is Ok(false) with a warn, never an error — the drop-in is on disk
/// either way and the next daemon install reloads it.
async fn reload_nft() -> bool {
match tokio::fs::try_exists(FIPS_NFT).await {
Ok(true) => {}
_ => return false,
}
match Command::new("sudo")
.args(["nft", "-f", FIPS_NFT])
.output()
.await
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"ssh-over-mesh: nft reload failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
false
}
Err(e) => {
tracing::warn!("ssh-over-mesh: nft reload failed: {e}");
false
}
}
}
/// Persist new state and reconcile immediately. Validation happens here so
/// an invalid source list can never reach disk, and reconcile reads back
/// exactly what was saved.
pub async fn set(
data_dir: &Path,
enabled: bool,
sources: &[String],
) -> Result<(SshMeshState, ReconcileOutcome)> {
let state = SshMeshState {
enabled,
sources: validate_sources(sources)?,
};
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?)
.await
.with_context(|| format!("write {}", state_path(data_dir).display()))?;
let outcome = reconcile(data_dir).await?;
Ok((state, outcome))
}
/// Preflights surfaced in the settings card. None of these gate the toggle —
/// they explain it: writing the rule on a node whose sshd doesn't listen on
/// IPv6 simply has no effect until sshd does, and the card says so instead of
/// the user discovering it as a silent connection failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshPreflights {
/// ssh.service (or sshd.service) is active.
pub sshd_active: bool,
/// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`).
/// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it.
pub sshd_ipv6_listen: bool,
/// sshd_config's PasswordAuthentication (last directive wins, includes
/// after the main file). None = not found / unreadable.
pub password_auth: Option<bool>,
}
pub async fn preflights() -> SshPreflights {
SshPreflights {
sshd_active: sshd_active().await,
sshd_ipv6_listen: sshd_ipv6_listen().await,
password_auth: password_auth_enabled().await,
}
}
async fn sshd_active() -> bool {
for unit in ["ssh", "sshd"] {
if let Ok(out) = Command::new("systemctl")
.args(["is-active", "--quiet", unit])
.output()
.await
{
if out.status.success() {
return true;
}
}
}
false
}
async fn sshd_ipv6_listen() -> bool {
let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else {
return false;
};
let text = String::from_utf8_lossy(&out.stdout);
text.lines().any(|line| {
let mut cols = line.split_whitespace();
// -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4.
let _state = cols.next();
let _recv = cols.next();
let _send = cols.next();
match cols.next() {
Some(local) => {
let port_ok = local.rsplit(':').next() == Some("22");
let v6 = local.starts_with("[::]") || local.starts_with('*');
port_ok && v6
}
None => false,
}
})
}
async fn password_auth_enabled() -> Option<bool> {
let mut directives: Vec<bool> = Vec::new();
if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await {
collect_password_auth(&main, &mut directives);
}
if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await {
for path in includes {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
collect_password_auth(&content, &mut directives);
}
}
}
directives.pop()
}
fn collect_password_auth(content: &str, out: &mut Vec<bool>) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") {
let rest = rest.trim_start();
let value = rest.split_whitespace().next().unwrap_or("");
if value.eq_ignore_ascii_case("yes") {
out.push(true);
} else if value.eq_ignore_ascii_case("no") {
out.push(false);
}
}
}
}
async fn glob_sorted(pattern: &str) -> Result<Vec<std::path::PathBuf>> {
let dir = std::path::Path::new(pattern)
.parent()
.unwrap_or_else(|| Path::new("/"));
let prefix = std::path::Path::new(pattern)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.split('.').next())
.unwrap_or("")
.to_string();
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut entries = tokio::fs::read_dir(dir)
.await
.context("read sshd_config.d")?;
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".conf") {
files.push(entry.path());
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_is_the_default_and_missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let state = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert!(!state.enabled);
assert!(state.sources.is_empty());
}
#[test]
fn any_peer_dropin_is_an_unrestricted_accept() {
let state = SshMeshState {
enabled: true,
sources: vec![],
};
let out = render_dropin(&state);
assert!(out.contains("tcp dport 22 accept"));
assert!(!out.contains("ip6 saddr"), "no saddr restriction expected");
}
#[test]
fn source_list_dropin_restricts_to_those_addresses() {
let state = SshMeshState {
enabled: true,
sources: vec![
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
"fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(),
],
};
let out = render_dropin(&state);
assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept"));
}
#[test]
fn sources_must_be_ipv6_and_are_normalised() {
let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err();
assert!(bad.to_string().contains("192.168.1.5"));
let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err();
assert!(bad.to_string().contains("not-an-address"));
// Uppercase/whitespace entries normalise to canonical lowercase.
let ok = validate_sources(&[
" FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(),
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
String::new(),
])
.unwrap();
assert_eq!(
ok,
vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]
);
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let state = SshMeshState {
enabled: true,
sources: vec!["fd00::1".to_string()],
};
std::fs::write(
dir.path().join(STATE_FILE),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
let loaded = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert_eq!(loaded, state);
}
#[test]
fn set_validates_before_persisting() {
let dir = tempfile::tempdir().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(set(dir.path(), true, &["bogus".to_string()]))
.unwrap_err();
assert!(err.to_string().contains("bogus"));
// Nothing was persisted.
let state = rt.block_on(load(dir.path()));
assert!(!state.enabled);
}
#[test]
fn preflight_parse_helpers_cover_the_directives() {
let mut directives = Vec::new();
collect_password_auth(
"# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n",
&mut directives,
);
assert_eq!(directives, vec![true, false]);
}
#[test]
fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() {
assert!(line_listens("[::]:22"));
assert!(line_listens("*:22"));
assert!(!line_listens("0.0.0.0:22"));
assert!(!line_listens("[::]:80"));
}
fn line_listens(local: &str) -> bool {
let line = format!("LISTEN 0 128 {local} 0.0.0.0:*");
let mut cols = line.split_whitespace();
cols.next();
cols.next();
cols.next();
match cols.next() {
Some(l) => {
let port_ok = l.rsplit(':').next() == Some("22");
let v6 = l.starts_with("[::]") || l.starts_with('*');
port_ok && v6
}
None => false,
}
}
}
+171 -37
View File
@@ -41,7 +41,7 @@ use crate::update::host_sudo;
/// Packages the node's host must have. Keep this list short and justified —
/// every entry is state we now own on the fleet's OS images.
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "rasdaemon"];
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
@@ -131,11 +131,10 @@ async fn run_host_fixups() -> Result<()> {
/// install without `apt-get update` first; only if that fails (fresh suite,
/// stale index), update once and retry. Both under timeout, both non-fatal.
async fn ensure_packages() -> Result<bool> {
let wanted = HOST_PACKAGES
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(" ");
// Package names are a fixed internal allowlist. Do not embed shell quote
// characters in WANTED: quotes produced by variable expansion are data,
// so dpkg-query would look for a package literally named 'kdump-tools'.
let wanted = HOST_PACKAGES.join(" ");
let script = format!(
r#"
set -u
@@ -197,25 +196,32 @@ exit 2
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
/// and on any state we already wrote — pure line surgery, idempotent.
async fn ensure_kdump_defaults() -> Result<()> {
let script = r#"
fn kdump_defaults_script(conf: &str) -> String {
r#"
set -u
CONF=/etc/default/kdump-tools
CONF='@@CONF@@'
[ -f "$CONF" ] || exit 3
CHANGED=0
# Remove the one malformed line emitted by the old systemd-run environment
# expansion bug before it was disabled. It makes every kdump-config invocation
# print an error while sourcing this file.
if grep -Fqx '=""' "$CONF"; then
sed -i '/^=""$/d' "$CONF"
CHANGED=1
fi
set_kv() {
# set_kv KEY VALUE — replace any (possibly commented) KEY= line with
# KEY='VALUE', appending at the end when absent.
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
# could append duplicates because their exact-value check did not accept
# double quotes; collapsing them also makes future passes idempotent.
KEY="$1"; VAL="$2"
if grep -qE "^${KEY}=" "$CONF" 2>/dev/null; then
if ! grep -qE "^${KEY}='?${VAL}'?$" "$CONF"; then
sed -i "s|^${KEY}=.*|${KEY}=\"${VAL}\"|" "$CONF"
CHANGED=1
fi
else
printf '\n%s="%s"\n' "$KEY" "$VAL" >> "$CONF"
CHANGED=1
EXPECTED="${KEY}=\"${VAL}\""
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
return
fi
sed -i "/^${KEY}=/d" "$CONF"
printf '\n%s\n' "$EXPECTED" >> "$CONF"
CHANGED=1
}
set_kv USE_KDUMP 1
set_kv KDUMP_COREDIR /var/crash
@@ -223,8 +229,13 @@ set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
[ "$CHANGED" -eq 1 ] || exit 0
systemctl enable kdump-tools >/dev/null 2>&1 || true
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
"#
.replace("@@CONF@@", conf)
}
async fn ensure_kdump_defaults() -> Result<()> {
let script = kdump_defaults_script("/etc/default/kdump-tools");
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("configure kdump-tools")?;
match status.code() {
@@ -237,24 +248,39 @@ exit 2
}
}
/// Append `crashkernel=` to the installed GRUB cmdline and run update-grub.
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
/// otherwise appends its own range-based reservation after ours; on amd64 that
/// silently wins and reserves only 192M instead of the intended 256M.
/// The reservation itself only exists after the next reboot — memory cannot
/// be set aside at runtime — so the caller must log the reboot caveat.
/// Returns true if the cmdline changed.
/// Returns true if the generated cmdline changed.
async fn ensure_crashkernel_cmdline() -> Result<bool> {
let script = format!(
r#"
set -u
GRUB=/etc/default/grub
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
PARAM='{CRASHKERNEL_PARAM}'
[ -f "$GRUB" ] || exit 3
CHANGED=0
# kdump-tools sources this after /etc/default/grub and unconditionally appends
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
CHANGED=1
fi
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
[ -n "$LINE" ] || exit 3
case "$LINE" in
*"$PARAM"*) exit 0 ;;
esac
NEWLINE=$(printf '%s' "$LINE" | sed "s/\"$/ $PARAM\"/")
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
# Remove any prior value before appending ours, so repeated fixups can never
# create conflicting parameters whose kernel precedence is easy to misread.
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
if [ "$NEWLINE" != "$LINE" ]; then
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
CHANGED=1
fi
[ "$CHANGED" -eq 1 ] || exit 0
timeout 120 update-grub >/dev/null 2>&1 || true
exit 2
"#
@@ -283,21 +309,30 @@ async fn ensure_rasdaemon_enabled() -> Result<()> {
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
/// fixup pass rather than by a timer: the pass runs at every startup, which is
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
async fn prune_crash_dumps() -> Result<()> {
let script = format!(
fn crash_dump_prune_script() -> String {
format!(
r#"
set -u
DIR=/var/crash
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
[ -d "$DIR" ] || exit 0
KEEP={KEEP_DUMPS}
COUNT=$(ls -1 "$DIR" 2>/dev/null | wc -l)
# kdump-tools keeps its lock and kexec command files beside timestamped dump
# directories. Count and prune directories only: treating those bookkeeping
# files as dumps can delete the sole freshly captured vmcore on startup.
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
[ "$COUNT" -gt "$KEEP" ] || exit 0
ls -1dt "$DIR"/* 2>/dev/null | tail -n +"$((KEEP + 1))" | while IFS= read -r victim; do
rm -rf -- "$victim"
done
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
| sort -zrn \
| tail -z -n +"$((KEEP + 1))" \
| cut -z -d ' ' -f 2- \
| xargs -0r rm -rf --
exit 2
"#
);
)
}
async fn prune_crash_dumps() -> Result<()> {
let script = crash_dump_prune_script();
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("prune /var/crash")?;
@@ -329,7 +364,10 @@ mod tests {
#[test]
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
assert_eq!(HOST_PACKAGES, &["kdump-tools", "kexec-tools", "rasdaemon"]);
assert_eq!(
HOST_PACKAGES,
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
);
}
#[test]
@@ -341,4 +379,100 @@ mod tests {
fn keep_dumps_is_two() {
assert_eq!(KEEP_DUMPS, 2);
}
#[test]
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
let conf = root.path().join("kdump-tools");
let bin = root.path().join("bin");
fs::create_dir(&bin).unwrap();
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
assert!(Command::new("chmod")
.args(["+x"])
.arg(bin.join("systemctl"))
.status()
.unwrap()
.success());
fs::write(
&conf,
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
)
.unwrap();
let script = kdump_defaults_script(conf.to_str().unwrap());
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
let first = Command::new("sh")
.args(["-lc", &script])
.env("PATH", &path)
.status()
.unwrap();
assert_eq!(first.code(), Some(2));
let repaired = fs::read_to_string(&conf).unwrap();
assert!(!repaired.lines().any(|line| line == "=\"\""));
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
assert!(repaired.contains("USE_KDUMP=\"1\""));
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
let second = Command::new("sh")
.args(["-lc", &script])
.env("PATH", path)
.status()
.unwrap();
assert!(second.success());
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
}
#[test]
fn crash_pruning_ignores_kdump_bookkeeping_files() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
let crash = root.path();
fs::write(crash.join("kdump_lock"), []).unwrap();
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
let path = crash.join(name);
fs::create_dir(&path).unwrap();
fs::write(path.join("vmcore"), name).unwrap();
assert!(Command::new("touch")
.args(["-d", &format!("@{epoch}")])
.arg(&path)
.status()
.unwrap()
.success());
}
let status = Command::new("sh")
.args(["-lc", &crash_dump_prune_script()])
.env("ARCHIPELAGO_CRASH_DIR", crash)
.status()
.unwrap();
assert_eq!(status.code(), Some(2));
assert!(!crash.join("old dump").exists());
assert!(crash.join("middle").join("vmcore").exists());
assert!(crash.join("newest").join("vmcore").exists());
assert!(crash.join("kdump_lock").exists());
assert!(crash.join("kexec_cmd").exists());
}
#[test]
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
fs::write(root.path().join(name), []).unwrap();
}
let status = Command::new("sh")
.args(["-lc", &crash_dump_prune_script()])
.env("ARCHIPELAGO_CRASH_DIR", root.path())
.status()
.unwrap();
assert!(status.success());
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
}
}
+31
View File
@@ -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:
+6
View File
@@ -1487,6 +1487,11 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
"--quiet",
"--collect",
"--pipe",
// Shell snippets passed as one argument must reach the child intact.
// systemd-run otherwise expands $VAR/${VAR} against the manager's
// environment before `sh -lc` can see them (and usually replaces them
// with empty strings).
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
@@ -1506,6 +1511,7 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
"--quiet",
"--collect",
"--pipe",
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
+30 -5
View File
@@ -1746,6 +1746,11 @@ app:
}
}
exempt.sort();
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
// same reason router's mDNS/SSDP and every p2p port is exempt; each
// carries its auth_rationale in the manifest.
//
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
@@ -1771,7 +1776,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
28,
30,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
@@ -1801,15 +1806,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
View File
@@ -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(())
}
}
+82 -14
View File
@@ -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 '{}'. \
+6
View File
@@ -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/
+43
View File
@@ -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"]
+393
View File
@@ -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
+109
View File
@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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: "java&#115;cript: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 &amp;#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
+15
View File
@@ -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`
+59
View File
@@ -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.
+1
View File
@@ -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": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"nodes": []
}
@@ -0,0 +1,4 @@
{
"commit": "archipelago-app",
"built": null
}
+80
View File
@@ -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
+17
View File
@@ -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

+57
View File
@@ -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>
+30
View File
@@ -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"
}
]
}
+72
View File
@@ -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;
}
}
}
+317
View File
@@ -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); });
}
+138
View File
@@ -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); });
}
+806
View File
@@ -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); });
}
+53
View File
@@ -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);
+111
View File
@@ -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);
}
+39
View File
@@ -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);
}
+379
View File
@@ -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);
}
+235
View File
@@ -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();
+82
View File
@@ -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);
+429
View File
@@ -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 };
}

Some files were not shown because too many files have changed in this diff Show More