Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d41b082d7 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 |
@@ -1,5 +1,13 @@
|
||||
# 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.
|
||||
|
||||
@@ -619,6 +619,27 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"8188:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/dojobay/data:/app/data",
|
||||
"/var/lib/archipelago/dojobay/server-data:/app/server/data"
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| dojobay | 8188 | TCP | Web UI | 18188 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
|
||||
+35
-14
@@ -45,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:
|
||||
@@ -82,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
|
||||
@@ -103,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
|
||||
@@ -128,21 +149,21 @@ app:
|
||||
# - 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 amishparadise
|
||||
# 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
|
||||
# (ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml,
|
||||
# verified 2026-09-01), which deliberately runs file logging quieter
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.10-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.10-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -6,7 +6,41 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -1814,11 +1814,24 @@ app:
|
||||
// (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),
|
||||
("dojobay".to_string(), 8188u16),
|
||||
("gitea".to_string(), 3001u16),
|
||||
("nginx-proxy-manager".to_string(), 8081u16),
|
||||
("tailscale".to_string(), 8240u16),
|
||||
|
||||
@@ -24,12 +24,12 @@ const TOLLGATE_VERSION: &str = "v0.5.0";
|
||||
/// 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",
|
||||
"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",
|
||||
"arm_cortex-a7" => "arm_cortex-a7",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
@@ -69,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
}
|
||||
|
||||
// Package not in any feed — download the .ipk directly.
|
||||
let arch = router
|
||||
.run_ok("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(|| {
|
||||
@@ -162,8 +163,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
size
|
||||
);
|
||||
}
|
||||
let (add_out, add_code) =
|
||||
router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Local test artifacts only — the shipped image seeds data/ and server/data/
|
||||
# from data-template/ at container start (see entrypoint.sh); nothing real
|
||||
# belongs in this build context.
|
||||
server/node_modules/
|
||||
data/
|
||||
server/data/
|
||||
@@ -0,0 +1,43 @@
|
||||
# Dojo Bay, packaged as an Archipelago app.
|
||||
#
|
||||
# Node 24 is required: the backend runs .ts directly via Node's type-stripping,
|
||||
# and its BIP47 libraries need it too (see the upstream project's README).
|
||||
# nginx serves the static directory site and proxies /api/ to the Node
|
||||
# backend in the same container — see nginx.conf for why both live here
|
||||
# instead of relying on a systemd pair the way the standalone deploy did.
|
||||
#
|
||||
# Runs fully rootless: no `user` directive in nginx.conf, so nginx's master
|
||||
# and worker processes just inherit whatever UID started them (dojobay,
|
||||
# below) — no privilege to drop, none ever held.
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:24-alpine
|
||||
RUN apk add --no-cache nginx tini \
|
||||
&& addgroup -S dojobay && adduser -S dojobay -G dojobay
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/server/node_modules /app/server/node_modules
|
||||
COPY server/ /app/server/
|
||||
COPY scripts/ /app/scripts/
|
||||
COPY assets/ /app/assets/
|
||||
COPY content/ /app/content/
|
||||
COPY types.d.ts /app/types.d.ts
|
||||
COPY index.html favicon.svg manifest.json sw.js /app/
|
||||
COPY data-template/ /app/data-template/
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /app/data /app/server/data \
|
||||
&& chown -R dojobay:dojobay /app \
|
||||
&& chown -R dojobay:dojobay /var/lib/nginx /var/log/nginx /run
|
||||
|
||||
USER dojobay:dojobay
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -q -O- http://127.0.0.1:8080/ >/dev/null || exit 1
|
||||
|
||||
# tini reaps the two children (node + nginx) and forwards signals cleanly.
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,393 @@
|
||||
/* Self-hosted variable fonts (latin subset). No external CDN. */
|
||||
@font-face{font-family:'Archivo';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/archivo.woff2') format('woff2')}
|
||||
@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;src:url('../fonts/hanken-grotesk.woff2') format('woff2')}
|
||||
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url('../fonts/jetbrains-mono.woff2') format('woff2')}
|
||||
|
||||
:root{
|
||||
--bg:#0a0a0a; --panel:#141414; --panel2:#1c1c1c; --line:#2a2a2a; --line-soft:#1c1c1c;
|
||||
--text:#f4f4f3; --muted:#a0a0a0; --faint:#6b6b6b;
|
||||
--accent:#b5302a; --accent-2:#d6534a; --accent-bg:rgba(181,48,42,.12); --accent-line:rgba(181,48,42,.34);
|
||||
--btc:#f7931a; --btc-text:#1a1206; --grey-sel:#8a8a8a;
|
||||
--up:#3fb950; --up-bg:rgba(63,185,80,.14); --down:#d6584f; --down-dim:#5a3330;
|
||||
/* Every use of this was written as var(--warn,#e0a020) against a token that
|
||||
was never declared, so the fallback always won. Declared here so the
|
||||
amber is adjustable in one place; --mid is the 90-day middle band, which
|
||||
was a bare hex literal for the same reason. */
|
||||
--warn:#e0a020; --mid:#b9a13a;
|
||||
/* Same class of bug, found by auditing every var() reference against the
|
||||
declarations: .admin-row asked for --card and had been taking #0e0e10 by
|
||||
fallback since it was written. Declared at that value rather than at
|
||||
--panel, so nothing changes appearance; whether the admin rows were meant
|
||||
to sit a shade darker than the cards they resemble is a separate
|
||||
question, and not one to answer by accident a second time. */
|
||||
--card:#0e0e10;
|
||||
--code-bg:#070707; --code-fg:#e6a39b;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{background:var(--bg);color:var(--text)}
|
||||
body{font-family:'Hanken Grotesk',system-ui,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
.mono{font-family:'JetBrains Mono',ui-monospace,monospace}
|
||||
.disp{font-family:'Archivo',sans-serif}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
||||
.wrap{max-width:1120px;margin:0 auto;padding:0 22px}
|
||||
.eyebrow{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint)}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
||||
|
||||
|
||||
header{border-bottom:1px solid var(--line-soft);position:sticky;top:0;background:rgba(11,11,12,.86);backdrop-filter:blur(8px);z-index:20}
|
||||
header .wrap{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 22px}
|
||||
.brand{display:flex;align-items:center;gap:12px}
|
||||
.brand .name{font-weight:800;font-size:18px;letter-spacing:-.01em}
|
||||
.brand .sub{font-size:10.5px;color:var(--faint);margin-top:1px;letter-spacing:.04em}
|
||||
nav{display:flex;gap:6px;align-items:center}
|
||||
nav .lnk{color:var(--muted);font-size:14px;padding:7px 11px;border-radius:7px;transition:color .15s,background .15s}
|
||||
nav .lnk:hover{color:var(--text);background:var(--panel)}
|
||||
.onion-pill{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);border:1px solid var(--accent-line);background:var(--accent-bg);padding:6px 10px;border-radius:7px;margin-left:4px}
|
||||
.onion-pill:hover{background:rgba(247,147,26,.16)}
|
||||
.burger{display:none;background:none;border:0;color:var(--text);padding:6px;cursor:pointer;border-radius:7px}
|
||||
/* The Auth47 challenge, shown under its QR. Wraps anywhere because it is one
|
||||
unbroken token, and carries its own copy button for the same-device case. */
|
||||
/* Column, always, at every width. Side by side the button lands wherever the
|
||||
URI happens to stop wrapping, so it sits mid-line on one screen and below
|
||||
on another, and on the narrow case it crowds the text it belongs to. The
|
||||
URI is a single unbroken token that has to wrap anyway, so there is no
|
||||
width at which a row reads better. */
|
||||
.a47-uri{display:flex;flex-direction:column;align-items:center;gap:10px;
|
||||
margin-top:10px;text-align:left}
|
||||
.a47-uri code{font-size:10.5px;color:var(--faint);word-break:break-all;line-height:1.5;
|
||||
max-width:44ch;width:100%}
|
||||
.a47-uri .copybtn{align-self:center}
|
||||
.upd-line{margin:6px 0}
|
||||
/* Self-update has never completed a run on real hardware. The badge is not
|
||||
decoration: a maintainer clicking Update from GitHub is the first person
|
||||
who will find out whether it works, and should know that before clicking. */
|
||||
.upd-exp{display:inline-block;font-size:10px;letter-spacing:.08em;text-transform:uppercase;
|
||||
font-family:'JetBrains Mono',monospace;color:var(--warn,#e0a020);
|
||||
border:1px solid rgba(224,160,32,.45);background:rgba(224,160,32,.10);
|
||||
border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:1px}
|
||||
/* Full width. It was capped at 62ch, which is right for prose a reader is
|
||||
settling into and wrong for a warning beside the control it warns about:
|
||||
it left the paragraph as a narrow column against a wide panel, and the
|
||||
ragged right edge read as a layout fault rather than as deliberate
|
||||
measure. */
|
||||
.upd-exp-note{font-size:11.5px;color:var(--faint);line-height:1.55;margin:8px 0 0;width:100%}
|
||||
.upd-none{font-size:11.5px;color:var(--faint);margin:6px 0 0}
|
||||
/* An update that did not finish. Warning-coloured rather than faint, because
|
||||
the failure it describes is invisible everywhere else: the code is on disk,
|
||||
the footer already shows the new build, and only the process serving the
|
||||
page is stale. */
|
||||
.upd-warn{font-size:12px;line-height:1.55;margin:8px 0 0;padding:9px 11px;border-radius:7px;
|
||||
color:#e9d6d2;background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.45)}
|
||||
.upd-warn b{color:var(--down)}
|
||||
.upd-controls{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||||
.upd-bar{height:8px;border-radius:6px;background:var(--panel2);overflow:hidden;margin:8px 0}
|
||||
.upd-bar-fill{height:100%;transition:width .4s ease;border-radius:6px}
|
||||
.upd-log{font-size:11px;color:var(--faint);background:var(--panel2);border-radius:8px;padding:8px 10px;margin:6px 0;white-space:pre-wrap;line-height:1.5;max-height:120px;overflow:auto}
|
||||
/* The import plan. Refused rows are coloured rather than hidden: a directory
|
||||
publishing listings this instance will not accept is the most informative
|
||||
thing on the table, and collapsing it to a count would bury it. */
|
||||
table.imp{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
|
||||
table.imp th{text-align:left;font-weight:600;color:var(--faint);font-size:11px;
|
||||
padding:4px 8px 4px 0;border-bottom:1px solid var(--line-soft)}
|
||||
table.imp td{padding:5px 8px 5px 0;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr.imp-merge td{color:var(--muted)}
|
||||
tr.imp-refuse td{color:var(--down)}
|
||||
.op-avatar{width:20px;height:20px;border-radius:50%;object-fit:cover;
|
||||
border:1px solid var(--line-soft);display:inline-block;vertical-align:middle}
|
||||
/* PayNym avatar centred on the pairing QR (QR is generated at EC level H,
|
||||
so the ~5% of symbol area the avatar covers is well within recovery) */
|
||||
.tile{position:relative}
|
||||
.qr-avatar{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:21%;height:21%;object-fit:cover;border-radius:8px;
|
||||
border:3px solid #fff;background:#fff}
|
||||
/* payment code chip on cards: truncated, click copies the full code */
|
||||
/* The payment code owns its own line and spans the card, so it reads as the
|
||||
identity of the listing rather than one chip among several. The verified
|
||||
domain and its verify button sit on the line beneath. */
|
||||
.pcode{display:block;width:100%;text-align:left;margin:2px 0 8px;padding:5px 11px;font-size:11.5px;
|
||||
letter-spacing:.02em;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
background:var(--panel2);border:1px solid var(--line-soft);border-radius:8px;cursor:pointer}
|
||||
.pcode:hover{color:var(--text);border-color:var(--accent)}
|
||||
.pcode.done{color:var(--up)}
|
||||
/* inline display-field editor (Manage rows and admin rows) */
|
||||
.medit{margin-top:10px;padding-top:10px;border-top:1px solid var(--line-soft);display:flex;flex-direction:column;gap:8px}
|
||||
.medit label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)}
|
||||
.medit input{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;color:var(--text);
|
||||
padding:7px 9px;font-size:13px;font-family:inherit}
|
||||
.medit input:focus{outline:none;border-color:var(--accent)}
|
||||
.medit-actions{display:flex;gap:8px;align-items:center}
|
||||
.copybtn[disabled],.abtn[disabled]{opacity:.4;cursor:default}
|
||||
.burger:hover{background:var(--panel)}
|
||||
|
||||
.controls{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:30px 22px 18px}
|
||||
.seg{display:inline-flex;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:3px}
|
||||
.seg button{font-family:'JetBrains Mono',monospace;font-size:12px;padding:8px 18px;border-radius:7px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);transition:background .15s,color .15s}
|
||||
.seg button.on{color:#0a0a0a;font-weight:700}
|
||||
.seg button[data-net="mainnet"].on{background:var(--btc);color:var(--btc-text)}
|
||||
.seg button[data-net="testnet"].on{background:var(--grey-sel);color:#0a0a0a}
|
||||
.fresh{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
||||
.fresh .dot{width:7px;height:7px;border-radius:99px;background:var(--up);display:inline-block;box-shadow:0 0 0 3px var(--up-bg)}
|
||||
.fresh b{color:var(--text);font-weight:700}
|
||||
.fresh .sep{color:var(--faint)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.card{background:var(--panel);border:1px solid var(--line-soft);border-radius:12px;padding:18px;transition:border-color .18s,transform .18s}
|
||||
.card:hover{border-color:var(--line);transform:translateY(-2px)}
|
||||
.card.inactive{opacity:.74}
|
||||
.ctop{display:flex;align-items:center;gap:10px}
|
||||
.ctop .sd{width:9px;height:9px;border-radius:99px;flex-shrink:0}
|
||||
.sd.active{background:var(--up);box-shadow:0 0 0 3px var(--up-bg)}
|
||||
.sd.inactive{background:var(--down);box-shadow:0 0 0 3px rgba(214,88,79,.14)}
|
||||
.cname{font-family:'Archivo',sans-serif;font-weight:700;font-size:16px;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
a.cname{transition:color .15s}
|
||||
a.cname:hover{color:var(--accent)}
|
||||
a.cname .ext{font-size:11px;color:var(--faint);vertical-align:middle}
|
||||
a.cname:hover .ext{color:var(--accent)}
|
||||
.cbadge{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:5px;font-weight:700;flex-shrink:0}
|
||||
/* Stale data: the updater has not refreshed dojos.json for several intervals,
|
||||
so we stop asserting status. Badges go neutral rather than green or red,
|
||||
because "unknown" is the honest answer, not "down". */
|
||||
/* The empty directory. Deliberately quiet: a bordered panel rather than a
|
||||
warning colour, because an instance with nothing published yet is usually
|
||||
new rather than broken. */
|
||||
.empty{border:1px dashed var(--line);border-radius:10px;padding:26px 22px;text-align:center;
|
||||
color:var(--muted);font-size:14px;line-height:1.65;margin:0 0 18px}
|
||||
.empty b{color:var(--text)}
|
||||
.empty-cta{margin-top:8px;font-size:13px;color:var(--faint)}
|
||||
/* The Tor port picker. Two presets, because there are two answers in practice
|
||||
and a free-text field would invite typos into the one value that has to be
|
||||
right for any of the commands below it to work. */
|
||||
.portpick{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0 14px}
|
||||
.portpick .k{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.06em;
|
||||
text-transform:uppercase;color:var(--faint)}
|
||||
.pbtn{font:inherit;font-family:'JetBrains Mono',monospace;font-size:13px;padding:6px 11px;
|
||||
border-radius:7px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer}
|
||||
.pbtn .w{display:block;font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;color:var(--faint);
|
||||
letter-spacing:0;text-transform:none;margin-top:1px}
|
||||
.pbtn:hover{border-color:var(--line-soft);color:var(--text)}
|
||||
.pbtn.on{border-color:var(--accent);color:var(--accent-2);background:rgba(181,48,42,.10)}
|
||||
.pbtn.on .w{color:var(--accent-2)}
|
||||
.stale-banner{margin:0 0 18px;padding:12px 14px;border-radius:8px;font-size:13.5px;line-height:1.6;
|
||||
color:var(--text);background:rgba(214,88,79,.10);border:1px solid rgba(214,88,79,.35)}
|
||||
.stale-banner b{color:var(--down)}
|
||||
.grid.stale .sd.active,.grid.stale .sd.inactive{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)}
|
||||
.grid.stale .cbadge.active,.grid.stale .cbadge.inactive{color:var(--faint);background:var(--panel2)}
|
||||
.grid.stale .card{opacity:.92}
|
||||
.fresh.stale .dot{background:var(--faint);box-shadow:0 0 0 3px rgba(139,148,158,.12)}
|
||||
.cbadge.active{color:var(--up);background:var(--up-bg)}
|
||||
.cbadge.inactive{color:var(--down);background:rgba(214,88,79,.12)}
|
||||
.csub{display:flex;align-items:center;gap:8px;margin:9px 0 2px;font-size:13px;flex-wrap:wrap}
|
||||
.csub .pn{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--accent)}
|
||||
.csub .pn:hover{text-decoration:underline}
|
||||
.csub .jur{color:var(--muted);display:inline-flex;align-items:center;gap:5px}
|
||||
.csub .flag{font-size:14px;line-height:1}
|
||||
.csub .nopn{color:var(--faint);font-style:italic;font-size:12.5px}
|
||||
|
||||
.rel{margin:15px 0 4px}
|
||||
.rel-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
|
||||
.rel-head .pct{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700}
|
||||
.rel-head .pct .n{color:var(--faint);font-weight:400}
|
||||
.rel-bars{display:flex;gap:2px;align-items:stretch;height:26px}
|
||||
.rel-bars .b{flex:1;min-width:2px;border-radius:1px;background:var(--down-dim)}
|
||||
.rel-bars .b.up{background:var(--up)}
|
||||
.rel-bars .b.down{background:var(--down)}
|
||||
.rel-axis{display:flex;justify-content:space-between;margin-top:5px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--faint)}
|
||||
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:11px 16px;margin:14px 0 4px}
|
||||
.meta .full{grid-column:1/-1}
|
||||
.meta .v{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--text);margin-top:2px;word-break:break-word}
|
||||
|
||||
.reveal{width:100%;padding:11px;border-radius:8px;background:var(--accent-bg);border:1px solid var(--accent-line);color:var(--accent-2);font-weight:600;font-size:13.5px;margin-top:14px;transition:background .15s}
|
||||
.reveal:hover{background:rgba(247,147,26,.16)}
|
||||
.reveal.open{color:var(--muted);border-color:var(--line)}
|
||||
/* Secondary action under the primary one: same shape, quieter, so pairing
|
||||
stays the obvious thing to click. */
|
||||
.reveal.secondary{background:var(--panel2);border-color:var(--line);color:var(--muted);
|
||||
font-weight:500;font-size:12.5px;padding:9px;margin-top:8px}
|
||||
.reveal.secondary:hover{color:var(--text);border-color:var(--line-soft);background:var(--panel2)}
|
||||
|
||||
.pair{margin-top:14px;animation:rise .25s ease}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
|
||||
.qr{display:flex;flex-direction:column;align-items:center;gap:7px;margin-bottom:14px}
|
||||
.qr .tile{background:#fff;border:1px solid var(--accent-line);border-radius:10px;padding:12px;line-height:0}
|
||||
.qr .tile svg{display:block;border-radius:2px}
|
||||
.qr .cap{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
|
||||
.box{margin-bottom:12px}
|
||||
.box .lbl,.modal-body>.lbl{display:flex;justify-content:space-between;align-items:center;margin-bottom:7px}
|
||||
.modal-body>.lbl{margin:18px 0 8px;gap:12px}
|
||||
.box .lbl .t,.modal-body>.lbl .t{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
|
||||
.box pre{font-family:'JetBrains Mono',monospace;font-size:10.5px;line-height:1.55;background:var(--code-bg);color:var(--code-fg);border:1px solid var(--line);border-radius:8px;padding:13px;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow:auto}
|
||||
.box.signed pre{color:#cdd6e4;font-size:10px}
|
||||
.copybtn{font-family:'JetBrains Mono',monospace;font-size:11px;padding:5px 11px;border:1px solid var(--accent-line);border-radius:6px;color:var(--accent);background:var(--accent-bg);transition:background .15s}
|
||||
.copybtn:hover{background:rgba(247,147,26,.18)}
|
||||
.copybtn.done{color:var(--up);border-color:rgba(63,185,80,.4);background:var(--up-bg)}
|
||||
.eps{margin-top:4px;display:flex;flex-direction:column;gap:8px}
|
||||
.card-eps{margin-top:14px;display:flex;flex-direction:column;gap:7px}
|
||||
.ep{display:flex;align-items:center;gap:9px}
|
||||
.ep .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);min-width:62px}
|
||||
.ep .u{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muted);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
/* Verified operator domain: a quiet badge, not a trust mark. It sits beside the
|
||||
payment-code chip and attests to control of the domain only. */
|
||||
.vdomain{display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace;
|
||||
font-size:11px;padding:4px 8px;border-radius:6px;text-decoration:none;
|
||||
color:var(--up);border:1px solid rgba(63,185,80,.35);background:var(--up-bg);white-space:nowrap;
|
||||
max-width:190px;overflow:hidden;text-overflow:ellipsis}
|
||||
.vdomain:hover{border-color:rgba(63,185,80,.6)}
|
||||
/* "For the machines among us": an unobtrusive way to interrogate the badge. */
|
||||
/* Domain and its verify button, on the line below the payment code. The
|
||||
domain takes the free space so a long one truncates instead of pushing the
|
||||
button off the card. */
|
||||
.vrow{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:nowrap}
|
||||
.vrow .vdomain{flex:1 1 auto;min-width:0;max-width:none}
|
||||
.vrow .vproof{flex:0 0 auto}
|
||||
.vproof{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.06em;
|
||||
padding:4px 7px;border-radius:6px;color:var(--faint);border:1px solid var(--line);
|
||||
background:var(--panel2);cursor:pointer}
|
||||
.vproof:hover{color:var(--muted);border-color:var(--line-soft)}
|
||||
/* A warning that must not be skimmed past: the XPUB advice is the one place
|
||||
where following the page carelessly could cost a reader their privacy. */
|
||||
.warnbox{border:1px solid rgba(214,88,79,.45);background:rgba(214,88,79,.10);
|
||||
border-radius:8px;padding:11px 13px;margin:0 0 12px;font-size:13px;line-height:1.6}
|
||||
.warnbox b{color:var(--down)}
|
||||
.proofblk{margin:0 0 12px}
|
||||
.proofblk .k{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--faint);margin-bottom:4px}
|
||||
.proofblk pre{margin:0 0 6px;padding:9px 10px;background:var(--panel2);border:1px solid var(--line);
|
||||
border-radius:6px;font-size:11.5px;white-space:pre-wrap;word-break:break-all;color:var(--muted)}
|
||||
/* Verified-domain setup box in Manage. */
|
||||
.dbox{border:1px solid var(--line);border-radius:8px;padding:12px 13px;background:var(--panel2);margin-bottom:12px}
|
||||
.dbox p{margin:0 0 8px}
|
||||
.dbox .dnote{font-size:12.5px;color:var(--muted)}
|
||||
.dbox .dmsg{font-size:12.5px;color:var(--accent);margin-top:8px;
|
||||
overflow-wrap:anywhere;word-break:break-word;line-height:1.5}
|
||||
/* Anything that can contain a payment code, an onion or a TXT value. */
|
||||
.dbox .dnote,.dbox .dwrap{overflow-wrap:anywhere;word-break:break-word}
|
||||
.dbox{overflow:hidden}
|
||||
.dbox .ok-tick{color:var(--up)}
|
||||
.dbox .ep{margin-bottom:6px}
|
||||
.dsign{font-size:11.5px;background:var(--panel);border:1px solid var(--line);border-radius:6px;
|
||||
padding:9px 10px;white-space:pre-wrap;word-break:break-all;color:var(--muted);margin:0 0 8px}
|
||||
.dbox textarea{width:100%;font-family:'JetBrains Mono',monospace;font-size:11.5px}
|
||||
.ep .copybtn{flex-shrink:0}
|
||||
/* An endpoint the node does not publish. The field keeps the same box as the
|
||||
other endpoints so the rows line up; only the text colour marks it as not
|
||||
being a value. The copy button stays in place, inert, so the row does not
|
||||
change width or lose its right-hand column. */
|
||||
.ep .u.na{color:var(--faint)}
|
||||
.copybtn[disabled]{opacity:.4;cursor:default;color:var(--faint);border-color:var(--line);background:var(--panel2)}
|
||||
.copybtn[disabled]:hover{background:var(--panel2)}
|
||||
|
||||
.note{margin:30px 0 8px;font-size:13.5px;color:var(--muted);line-height:1.65}
|
||||
.note a{color:var(--accent);font-weight:600}
|
||||
.note a:hover{text-decoration:underline}
|
||||
|
||||
footer{border-top:1px solid var(--line-soft);padding:24px 0;margin-top:18px}
|
||||
footer .wrap{display:flex;justify-content:center}
|
||||
footer .gh{color:var(--faint);display:inline-flex;align-items:center;transition:color .15s}
|
||||
footer .gh:hover{color:var(--text)}
|
||||
footer .gh svg{display:block}
|
||||
|
||||
.ov{position:fixed;inset:0;background:rgba(4,4,5,.72);backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;padding:6vh 18px;z-index:50;overflow:hidden}
|
||||
.ov.show{display:flex}
|
||||
.modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;max-width:700px;width:100%;padding:0;box-shadow:0 24px 60px rgba(0,0,0,.5);display:flex;flex-direction:column;max-height:88vh;min-height:0;overflow:hidden}
|
||||
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--line-soft);background:var(--panel);border-radius:14px 14px 0 0;flex:0 0 auto}
|
||||
.modal-head h2{font-family:'Archivo',sans-serif;font-size:19px;font-weight:700}
|
||||
.modal-head .x{font-size:22px;color:var(--muted);line-height:1;padding:2px 8px;border-radius:6px}
|
||||
.modal-head .x:hover{background:var(--panel2);color:var(--text)}
|
||||
.modal-body{padding:22px 24px 26px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
|
||||
.modal-body p{font-size:14px;color:#d7d7d4;line-height:1.7;margin-bottom:13px}
|
||||
.modal-body h2{font-family:'Archivo',sans-serif;font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);margin:26px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--line-soft)}
|
||||
.modal-body h2:first-child{margin-top:0}
|
||||
.modal-body h3{font-family:'Archivo',sans-serif;font-weight:700;color:var(--text);font-size:14.5px;margin:18px 0 4px}
|
||||
.modal-body strong{color:var(--text)}
|
||||
.modal-body a{color:var(--accent);font-weight:600;word-break:break-word}
|
||||
.modal-body a:hover{text-decoration:underline}
|
||||
.modal-body ul{margin:0 0 13px 2px;padding:0;list-style:none}
|
||||
.modal-body li{font-size:14px;color:#d7d7d4;line-height:1.6;margin-bottom:6px;padding-left:2px}
|
||||
.modal-body code{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent);background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:2px 6px}
|
||||
.modal-body blockquote{background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:14px 16px;margin:0 0 16px}
|
||||
.modal-body blockquote p{font-size:13.5px;margin-bottom:9px}
|
||||
.modal-body blockquote p:last-child{margin-bottom:0}
|
||||
.modal-body blockquote code{display:inline-block;color:var(--accent);font-size:14px}
|
||||
.modal-body .loading{color:var(--faint);font-family:'JetBrains Mono',monospace;font-size:13px}
|
||||
@media (max-width:560px){
|
||||
.meta{grid-template-columns:1fr}
|
||||
/* The network toggle and the freshness line sit at opposite ends of one row
|
||||
on a wide screen, which is what space-between is for. On a narrow one they
|
||||
wrap onto separate lines, and space-between then puts a lone item at the
|
||||
start of its line, so both ended up hard against the left edge under a
|
||||
centred header. Centre them instead: the toggle is the page's primary
|
||||
control and reads as a control rather than a stray pair of words when it
|
||||
is centred under the title.
|
||||
.fresh is itself a flex container whose own content wraps, so it needs
|
||||
centring too, or its second line ("re-checks every 10 min") hangs left
|
||||
under a centred first line, which looks like a mistake rather than a
|
||||
wrap. text-align covers any inline content that is not a flex item. */
|
||||
.controls{justify-content:center;gap:12px;padding:22px 18px 14px}
|
||||
.fresh{justify-content:center;text-align:center}
|
||||
header .wrap{padding:14px 18px;position:relative;justify-content:flex-start}
|
||||
.burger{display:block;z-index:2}
|
||||
/* Centre the title while the hamburger is in use. Only then: taking .brand
|
||||
out of flow leaves the burger as the header's only in-flow child, and on
|
||||
a page that has no burger the header collapses to its padding, so the
|
||||
brand overlaps whatever is beneath it. That is what the operator console
|
||||
looked like on a phone, its title clipped over the Moderation heading,
|
||||
and its one nav link was unreachable as well because the nav becomes a
|
||||
dropdown with nothing to open it. */
|
||||
header:not(.no-menu) .brand{position:absolute;left:50%;transform:translateX(-50%)}
|
||||
header.no-menu .wrap{justify-content:space-between;gap:10px}
|
||||
header.no-menu nav{display:flex;position:static;flex-direction:row;background:none;
|
||||
backdrop-filter:none;border:0;padding:0}
|
||||
header.no-menu nav .lnk{padding:8px 10px;font-size:14px;white-space:nowrap}
|
||||
header.no-menu .brand .name{font-size:16px}
|
||||
/* the nav becomes a full-width dropdown under the header */
|
||||
nav{display:none;position:absolute;top:100%;left:0;right:0;flex-direction:column;align-items:stretch;gap:2px;
|
||||
background:rgba(11,11,12,.97);backdrop-filter:blur(8px);border-bottom:1px solid var(--line-soft);padding:8px 14px 12px}
|
||||
nav.open{display:flex}
|
||||
nav .lnk{display:block;text-align:center;padding:12px;font-size:15px}
|
||||
nav .onion-pill{text-align:center;margin:6px 0 0}
|
||||
/* Less chrome around the dialog on a small screen, so the body gets the
|
||||
height. The scrolling still happens inside .modal-body. */
|
||||
.ov{padding:3vh 10px}
|
||||
.modal{max-height:94vh}
|
||||
.modal-head{padding:16px 18px}
|
||||
.modal-body{padding:18px 18px 22px}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.card:hover{transform:none}.pair{animation:none}}
|
||||
|
||||
/* Manage my Dojo form */
|
||||
.mform{display:flex;flex-direction:column;gap:12px}
|
||||
.mform label{display:flex;flex-direction:column;gap:5px;font-size:12.5px;color:var(--muted)}
|
||||
.mform input,.mform select,.mform textarea{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:9px 10px;font-family:'JetBrains Mono',monospace;font-size:12.5px;width:100%}
|
||||
.mform textarea{resize:vertical;line-height:1.5}
|
||||
.mform input:focus,.mform select:focus,.mform textarea:focus{outline:none;border-color:var(--accent-line)}
|
||||
|
||||
/* 90-day daily history (on the card, below the 24h strip) */
|
||||
.hist90{margin-top:12px}
|
||||
.d90strip{display:flex;gap:1px;align-items:flex-end;height:22px;margin:8px 0 6px}
|
||||
.d90{flex:1 1 0;min-width:1px;height:100%;border-radius:1px;background:var(--line)}
|
||||
.d90.up{background:var(--up)} .d90.mid{background:var(--mid)} .d90.down{background:var(--down)} .d90.na{background:var(--line)}
|
||||
.d90foot{display:flex;justify-content:space-between;font-size:11px;font-family:'JetBrains Mono',monospace}
|
||||
.spark{display:block;margin-top:6px;opacity:.9}
|
||||
|
||||
footer .ver{margin-left:12px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--faint)}
|
||||
footer .ver a{color:var(--faint)} footer .ver a:hover{color:var(--text)}
|
||||
|
||||
/* footer verify link */
|
||||
footer .wrap{display:flex;align-items:center;gap:10px}
|
||||
.foot-spacer{flex:1}
|
||||
.verify-pre{background:var(--panel,#111);border:1px solid var(--line);border-radius:8px;padding:12px;font-family:'JetBrains Mono',monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text);margin-top:6px}
|
||||
|
||||
/* admin console */
|
||||
.admin-row{border:1px solid var(--line);border-radius:10px;padding:14px;margin:10px 0;background:var(--card,#0e0e10)}
|
||||
.admin-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}
|
||||
.abadge{font-size:10px;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--muted)}
|
||||
.abadge.pending{color:#b9a13a;border-color:#b9a13a}
|
||||
.abadge.approved{color:var(--up);border-color:var(--up)}
|
||||
.abadge.rejected{color:var(--down);border-color:var(--down)}
|
||||
.admin-actions{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
|
||||
.abtn{font:inherit;font-size:13px;padding:7px 14px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}
|
||||
.abtn.ok{border-color:var(--up);color:var(--up)}
|
||||
.abtn.danger{border-color:var(--down);color:var(--down)}
|
||||
.abtn:disabled{opacity:.5}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
// Minimal, dependency-free Markdown renderer.
|
||||
// Supports the subset used by the content/*.md files: headings (#..######),
|
||||
// paragraphs, unordered lists (- / *), blockquotes (>), and the inline forms
|
||||
// **bold**, `code`, and [text](url). HTML in the source is escaped, so content
|
||||
// authors can write plain Markdown without worrying about markup.
|
||||
//
|
||||
// ON TRUST. Everything this renders today is written by whoever maintains the
|
||||
// instance and shipped in the repository: content/about.md and content/faq.md,
|
||||
// and nothing else calls markdown.render. Under that assumption the escaping
|
||||
// below is a convenience, not a boundary, because an author who wanted a script
|
||||
// tag on the page could simply put one in index.html.
|
||||
//
|
||||
// It is nonetheless written as though the input were hostile, because the gap
|
||||
// between "only maintainers write this" and "anyone can" is one call site. If a
|
||||
// future change renders ANY of the following, this file becomes a real security
|
||||
// boundary and should be read again with that in mind:
|
||||
// - a submission field (node name, jurisdiction, hardware, the operator note)
|
||||
// - anything fetched from another instance, including during a bootstrap
|
||||
// import or a federated update
|
||||
// - a file an operator can drop into content/ without a commit
|
||||
// Two things in particular were fixed ahead of that day: the quote character
|
||||
// was not escaped, so a link URL could close the href attribute and open a new
|
||||
// one (browsers accept `href="x"onfocus=…` without whitespace); and any scheme
|
||||
// at all was accepted, so javascript: and data: URLs became live links.
|
||||
(function (global) {
|
||||
// Quotes included. Without them, escaping is enough for TEXT but not for an
|
||||
// attribute value, and the link rule below interpolates into href="…".
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// An allowlist, not a denylist of the schemes that happen to be dangerous
|
||||
// today. http and https cover every link in the content and every link a
|
||||
// reader of an onion site should be following; anything else, including
|
||||
// javascript:, data:, vbscript: and file:, renders as plain text so the
|
||||
// author can see their link did not work rather than shipping a live one.
|
||||
//
|
||||
// Applied to the RAW url, before entity-escaping: "javascript:x" is not
|
||||
// a scheme this accepts, and the check must not be fooled by a spelling that
|
||||
// only becomes a scheme after the browser decodes it. Leading control
|
||||
// characters and whitespace are stripped first for the same reason, since
|
||||
// browsers ignore them when resolving a URL.
|
||||
function safeUrl(u) {
|
||||
const cleaned = u.replace(/[\u0000-\u0020]/g, "");
|
||||
// A scheme is everything before the first colon, if that comes before the
|
||||
// first slash, question mark or hash. No colon in that position means a
|
||||
// relative URL, which cannot execute anything.
|
||||
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
||||
if (!m) return !/^\/\//.test(cleaned) ? cleaned : null; // protocol-relative is not relative
|
||||
const scheme = m[1].toLowerCase();
|
||||
return scheme === "http" || scheme === "https" ? cleaned : null;
|
||||
}
|
||||
|
||||
function inline(s) {
|
||||
s = escapeHtml(s);
|
||||
s = s.replace(/`([^`]+)`/g, (_, c) => "<code>" + c + "</code>");
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, t, u) => {
|
||||
// u arrives already entity-escaped, and that is fine to judge directly:
|
||||
// none of & < > " ' is a legal scheme character, so escaping cannot turn
|
||||
// a dangerous scheme into an acceptable one or the reverse. Decoding
|
||||
// first, which an earlier version did to "see what the browser sees",
|
||||
// bought nothing and introduced a double-unescape (CodeQL js/double-
|
||||
// escaping) where &#39; unwound one layer too many.
|
||||
if (!safeUrl(u)) return whole; // leave the markdown visible, unlinked
|
||||
return '<a href="' + u + '" target="_blank" rel="noopener">' + t + "</a>";
|
||||
});
|
||||
return s;
|
||||
}
|
||||
function render(md) {
|
||||
const lines = String(md).replace(/\r\n/g, "\n").split("\n");
|
||||
let html = "", i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^\s*$/.test(line)) { i++; continue; }
|
||||
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) { const l = h[1].length; html += `<h${l}>${inline(h[2].trim())}</h${l}>`; i++; continue; }
|
||||
|
||||
if (/^\s*>/.test(line)) { // blockquote (recurses)
|
||||
const block = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i])) { block.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
||||
html += "<blockquote>" + render(block.join("\n")) + "</blockquote>";
|
||||
continue;
|
||||
}
|
||||
if (/^\s*[-*]\s+/.test(line)) { // unordered list
|
||||
html += "<ul>";
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
html += "<li>" + inline(lines[i].replace(/^\s*[-*]\s+/, "")) + "</li>"; i++;
|
||||
}
|
||||
html += "</ul>";
|
||||
continue;
|
||||
}
|
||||
const para = []; // paragraph
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i]) &&
|
||||
!/^(#{1,6})\s/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i])) {
|
||||
para.push(lines[i].trim()); i++;
|
||||
}
|
||||
html += "<p>" + inline(para.join(" ")) + "</p>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
const api = { render };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.markdown = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
The Dojo Bay exists to give access to people who don't have a Dojo of their own. We encourage everyone to run their own node rather than rely on third parties, and we collect nothing about the people who connect through this directory.
|
||||
|
||||
This site is run by a Dojo operator, and one or more of the nodes listed here are ours. We think that is the right arrangement: whoever maintains a directory of public Dojos should be exposed to the same costs and the same risks as everyone in it. It also means we are not a neutral party, which is precisely why nothing here asks you to take our word for anything.
|
||||
|
||||
**Every listing carries a pairing payload signed by its operator.** That signature is made with the key behind their BIP47 payment code, over the exact onion address, API key and explorer you are about to use, and you can check it with your own wallet or an independent verifier without trusting this site at all. If we were compromised, or simply dishonest, we could not substitute our own onion address into someone else's listing without the signature failing. Because we are a federation of individuals in different jurisdictions we still cannot vouch for how any operator behaves once you connect, but you no longer have to assume the details we publish are the ones they gave us.
|
||||
|
||||
We cannot control when a node goes down, as only its operator can restart it. We make an effort to keep the directory showing only running dojos and re-check every node on a 10-minute cycle, but please conduct your own due diligence.
|
||||
|
||||
We are not affiliated with [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/), [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or Ronin Dojo, though we appreciate their efforts and contributions to the community.
|
||||
|
||||
> **Get listed**
|
||||
>
|
||||
> If you would like your Dojo listed, there is no email and nothing to wait for: open **Manage my Dojo** in the header and sign in with your PayNym over Auth47. Signing the challenge in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) proves you control the payment code without revealing any key, and you can then submit, edit or remove your listing yourself. Every submission must pass a live Tor connection check, a signature check over your pairing payload, and a maintainer review before it is published.
|
||||
>
|
||||
> `Manage my Dojo → Auth47 → sign → submit`
|
||||
@@ -0,0 +1,59 @@
|
||||
## For Dojo seekers
|
||||
|
||||
> **Don't delete your wallet without your passphrase**
|
||||
>
|
||||
> Your [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) passphrase is shown only once, when the wallet is created, and is separate from the PIN you use to open the app; the two are not linked. To switch the Dojo your wallet connects to you must delete and re-create the wallet, so confirm you have the correct passphrase first. The passphrase cannot be recovered, and you need both the 12-word seed phrase and the passphrase to restore a wallet. To check a passphrase, go to **Settings → Wallet → Check BIP39 Passphrase**.
|
||||
>
|
||||
> 🔴 No passphrase: do not delete the wallet. Send the funds to a wallet you control instead.
|
||||
>
|
||||
> 🟢 Passphrase and 12 words: you can safely delete the wallet to change device or connect to another Dojo.
|
||||
>
|
||||
> If you have the passphrase but not the 12 words, you can still open the wallet by decrypting the backup file with the passphrase. If you lose the Dojo connection and don't have the passphrase, export the XPUB to Sparrow for a watch-only wallet and sign offline from [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
### Who is responsible for the listed nodes?
|
||||
|
||||
Not The Dojo Bay: this site is a **directory only**. We do not operate the nodes listed here, we cannot guarantee their uptime, honesty or safety, and we accept no responsibility for them or for any loss of funds or privacy. Status and reliability figures come from automated checks and can be wrong or out of date. Treat every listing as untrusted: verify the pairing details, prefer self-hosting, and connect at your own risk.
|
||||
|
||||
### Are there privacy concerns for Dojo seekers?
|
||||
|
||||
Yes. When you pair with a Dojo you share your extended public key (XPUB), and the operator can use it to view your past, present and future transactions. Only connect to a Dojo you consider reputable and trustworthy, and prefer your own node whenever possible.
|
||||
|
||||
### How do I verify a listing?
|
||||
|
||||
Every listing here is signed, so there is always something to check. Start with the PayNym: confirm it belongs to someone whose reputation you can check, whether stated in a social-media bio, on their own site, or mentioned publicly, and look it up in the [PayNym.rs](http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion) directory to see its code. Then take the signed message from the listing to the [BIP47 Message Verifier](http://ab64uow264ohynkalvlyhdrduwwl75n4urvc2vrbo3xjd4jycygiirqd.onion/lab) and fill in the fields; a correct message returns "Message verified successfully". If verification fails there, use **Tools → Verify message** inside [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
What this proves is narrow and worth being precise about. It proves that whoever holds the key behind that payment code published these exact pairing details, so the onion address and API key you are about to use are the ones their operator put their name to and not something substituted afterwards. It does not prove they are honest, that the node is well run, or that the payment code belongs to the person you think it does. That last part is your job, and it is why the PayNym step comes first.
|
||||
|
||||
### Why doesn't the site verify the signatures for me?
|
||||
|
||||
Because a page that checks its own claims is asking to be trusted twice. If this instance were compromised it could show a green tick over a forged listing just as easily as a real one, so verification done here would be worth nothing at the exact moment you needed it. Doing it in your own wallet or in an independent verifier is the only version of the check that survives us being wrong or dishonest, so we make that as easy as we can and deliberately stop short of doing it for you.
|
||||
|
||||
### Where do I learn to run my own Dojo?
|
||||
|
||||
A Dojo can be installed several ways: [RoninDojo](https://ronindojo.io), a vanilla Dojo (instructions at [dojo-osp.org](https://dojo-osp.org)), or through the [Umbrel](https://apps.umbrel.com/app/samourai-server), [Nodl](https://nodl.eu) and [Start9](https://marketplace.start9.com) marketplaces. It runs on almost any Bitcoin node implementation, giving you full control of your [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) / [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) backend. Treat any public Dojo as strictly temporary or for testing: once your own node is running, migrate your funds to fresh addresses managed by your instance to avoid reusing previously exposed public keys.
|
||||
|
||||
## For Dojo runners
|
||||
|
||||
### Are there privacy concerns for Dojo runners?
|
||||
|
||||
Not security concerns so much as exposure ones. By sharing a pairing payload you reveal your Dojo's onion address, which a malicious party could try to DDoS. You also risk a large number of wallets pairing to your Dojo, so size your hardware accordingly. Until API-key management is fully in place you cannot un-share your pairing details once published.
|
||||
|
||||
### What do I have to sign, and when?
|
||||
|
||||
Your pairing payload, at submission, and again whenever you change it. The signature covers the exact JSON you publish, so a new onion address or a rotated API key needs a new signature over the new details: the old one attests to what you are replacing and will be refused. Sign it with the same PayNym you sign in with, under **PayNym → Sign message** in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/), and paste the whole block including its headers.
|
||||
|
||||
### Is there a minimum Dojo version?
|
||||
|
||||
Yes, 1.27.0, judged on the version your node reports when we probe it rather than the one written into your pairing payload. If your node reports older than that, upgrade it before submitting.
|
||||
|
||||
### Can I change the onion address if I'm being DDoSed?
|
||||
|
||||
Yes, but you will have to re-pair every connected wallet, and update the listing here with a signed payload covering the new address (see above). Until you do, the directory keeps publishing the old one and your listing will show as down.
|
||||
|
||||
### Can I see how many wallets are connected to my Dojo?
|
||||
|
||||
No, and that will not be possible.
|
||||
|
||||
### Can I cap the number if my hardware is limited?
|
||||
|
||||
It isn't really about connections but about tracking a very large number of addresses, and that limit is high even on lower-grade devices.
|
||||
@@ -0,0 +1 @@
|
||||
{ "generated_at": null, "interval_minutes": 10, "nodes": [] }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"retention_days": 90,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"interval_minutes": 10,
|
||||
"window_checks": 72,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"source": "https://paynym.rs/api/v1/nym",
|
||||
"mapping": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"nodes": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"commit": "archipelago-app",
|
||||
"built": null
|
||||
}
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
# Dojo Bay container entrypoint: seeds first-run data, points the backend at
|
||||
# Archipelago's Tor SOCKS proxy, runs the 10-minute prober on a loop (in place
|
||||
# of the systemd timer the standalone deploy used), and supervises all three
|
||||
# processes (node backend, prober loop, nginx) so a SIGTERM from tini/podman
|
||||
# stops them all cleanly rather than leaving orphans for the hard-kill timeout.
|
||||
set -eu
|
||||
|
||||
# ---- first-run data seeding -------------------------------------------------
|
||||
# /app/data is a bind-mounted, host-persistent volume: empty on first install,
|
||||
# and shadows whatever was baked into the image at that path. Populate it from
|
||||
# the clean templates exactly once; a real seed.json/operator.json (once the
|
||||
# claim wizard or "Manage my Dojo" writes one) is never overwritten.
|
||||
for f in seed.json dojos.json history.json history-daily.json paynym-codes.json version.json; do
|
||||
if [ ! -f "/app/data/$f" ]; then
|
||||
cp "/app/data-template/$f" "/app/data/$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- outbound Tor -----------------------------------------------------------
|
||||
# The manifest generates /app/data/tor-proxy.conf with the archy-net bridge
|
||||
# gateway's SOCKS address (Archipelago's Tor binds a second SocksPort there
|
||||
# specifically for containers) — see docs/app-developer-guide.md's
|
||||
# {{NETWORK_GATEWAY}} placeholder. probe.mjs already reads TOR_SOCKS_HOST/PORT
|
||||
# (used for PayNym lookups, DNS-over-HTTPS domain checks, and probing every
|
||||
# listed Dojo), so no code change is needed, only wiring the env vars here.
|
||||
if [ -f /app/data/tor-proxy.conf ]; then
|
||||
TOR_PROXY_ADDR="$(cat /app/data/tor-proxy.conf)"
|
||||
export TOR_SOCKS_HOST="${TOR_PROXY_ADDR%:*}"
|
||||
export TOR_SOCKS_PORT="${TOR_PROXY_ADDR##*:}"
|
||||
fi
|
||||
|
||||
# ---- the backend -------------------------------------------------------------
|
||||
cd /app/server
|
||||
node index.mjs &
|
||||
NODE_PID=$!
|
||||
|
||||
# ---- the 10-minute prober ----------------------------------------------------
|
||||
# Replaces dojobay-update.timer: the same script, invoked on a loop instead of
|
||||
# by systemd. update.mjs itself is unchanged from upstream. Runs once shortly
|
||||
# after start (dojobay-update.timer's OnBootSec=2min counterpart — a fresh
|
||||
# install should not sit on an empty/stale list for a full ten minutes), then
|
||||
# every 10 minutes; a few seconds of random jitter on each wait, same reasoning
|
||||
# as the timer's RandomizedDelaySec (a fleet of instances should not all probe
|
||||
# the same nodes on the same wall-clock tick).
|
||||
(
|
||||
sleep "$((25 + RANDOM % 30))"
|
||||
while true; do
|
||||
node /app/scripts/update.mjs || echo "[update] cycle failed, will retry in 10 minutes" >&2
|
||||
sleep "$((570 + RANDOM % 60))"
|
||||
done
|
||||
) &
|
||||
UPDATE_LOOP_PID=$!
|
||||
|
||||
# ---- the web server -----------------------------------------------------------
|
||||
# Backgrounded rather than exec'd: this script stays the live PID tini
|
||||
# supervises, so the trap below can actually run when SIGTERM arrives and
|
||||
# forward it to all three children. (exec'ing nginx here would replace this
|
||||
# script's process image, and a trap registered by a process that no longer
|
||||
# exists never fires — the other two would then only die on the container's
|
||||
# hard-kill timeout instead of shutting down cleanly.)
|
||||
# -e /dev/stderr: nginx's master process logs its very first startup lines
|
||||
# (before it has even parsed nginx.conf's own error_log directive) to a
|
||||
# compiled-in default path under /var/lib/nginx/logs — a symlink to
|
||||
# /var/log/nginx, which is not one of the paths this app asks Archipelago to
|
||||
# make writable under security.readonly_root. Overriding it here means
|
||||
# nothing ever depends on /var/log/nginx existing or being writable at all,
|
||||
# on this image or any other readonly-root host.
|
||||
nginx -e /dev/stderr -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
cleanup() {
|
||||
kill -TERM "$NGINX_PID" "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true
|
||||
wait "$NGINX_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait "$NGINX_PID"
|
||||
kill "$NODE_PID" "$UPDATE_LOOP_PID" 2>/dev/null || true
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="114" fill="#0b0b0c"/>
|
||||
<g transform="translate(24,-58.5) scale(1.45)">
|
||||
<g fill="#b5302a">
|
||||
<path d="M40 96 Q160 112 280 96 L280 116 Q160 132 40 116 Z"/>
|
||||
<path d="M154 116 H166 V124 H154 Z"/>
|
||||
<path d="M74 124 H246 V144 H74 Z"/>
|
||||
<path d="M104 126 H124 L118 250 H98 Z"/>
|
||||
<path d="M196 126 H216 L222 250 H202 Z"/>
|
||||
</g>
|
||||
<g stroke="#d6534a" stroke-width="14" stroke-linecap="round" fill="none">
|
||||
<path d="M50 272 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0"/>
|
||||
<path d="M50 300 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".72"/>
|
||||
<path d="M50 328 q13.75 -13 27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0 t27.5 0" opacity=".48"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<!-- Onion-Location advertises a .onion to clearnet visitors. Irrelevant while
|
||||
onion-only; when a clearnet domain exists, set it in nginx (see deploy/). -->
|
||||
<title>The Dojo Bay — Public Dojo Directory</title>
|
||||
<meta name="description" content="A community directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets. All nodes reachable over Tor." />
|
||||
|
||||
<!-- Open Graph / Twitter link-preview tags live on the clearnet mirror
|
||||
(dojobay.org), which is the front door that introduces this onion service
|
||||
to new users. Tor clients never render social previews and clearnet
|
||||
crawlers cannot fetch a relative og:image over .onion, so the tags and the
|
||||
og-image.png asset were dead weight here and have been removed. -->
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="assets/icons/192x192.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dojo Bay" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- self-hosted fonts (no external CDN) -->
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/hanken-grotesk.woff2" crossorigin />
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/archivo.woff2" crossorigin />
|
||||
<link rel="preload" as="font" type="font/woff2" href="assets/fonts/jetbrains-mono.woff2" crossorigin />
|
||||
<link rel="stylesheet" href="assets/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="max-width:640px;margin:14vh auto;padding:0 22px;font-family:sans-serif;color:#f4f4f3">
|
||||
<h1 style="font-size:22px">JavaScript is required</h1>
|
||||
<p style="color:#a0a0a0;line-height:1.7">This directory renders its node list, pairing QR codes and status
|
||||
client-side. Enable JavaScript for this site (in Tor Browser, the "Safest" security level blocks it),
|
||||
or fetch the raw data directly at <code style="color:#e6a39b">data/dojos.json</code>.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<!-- vendored, dependency-free QR encoder (qrcode-generator, MIT) -->
|
||||
<script src="assets/js/qrcode.js"></script>
|
||||
<!-- tiny markdown renderer for content/*.md -->
|
||||
<script src="assets/js/markdown.js"></script>
|
||||
<!-- directory UI -->
|
||||
<script src="assets/js/app.js"></script>
|
||||
|
||||
<!-- PWA: register the service worker (no-op if unsupported) -->
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "The Dojo Bay",
|
||||
"short_name": "Dojo Bay",
|
||||
"description": "A community directory of public Bitcoin Dojo nodes for Samourai, Ashigaru and Sentinel wallets. All nodes reachable over Tor.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Dojo Bay, containerized for Archipelago.
|
||||
#
|
||||
# Adapted from the upstream project's deploy/nginx-onion.conf.example. The
|
||||
# Tor hidden service, TLS-equivalent framing and moderation-queue trust
|
||||
# decisions all belong to Archipelago's app gate now (it fronts every gated
|
||||
# port with its own onion, strips clickjacking headers for iframe embedding,
|
||||
# and enforces the manifest's auth policy) — this file keeps only what is
|
||||
# still this app's own job: serving the static directory site and proxying
|
||||
# its self-service API to the Node backend running in the same container.
|
||||
worker_processes 1;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css text/javascript application/javascript application/json image/svg+xml text/markdown;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /app;
|
||||
index index.html;
|
||||
|
||||
# The directory data is rewritten every 10 minutes by scripts/update.mjs —
|
||||
# keep it fresh rather than letting a browser cache it for a day like the
|
||||
# other static assets below.
|
||||
location /data/ {
|
||||
add_header Cache-Control "max-age=60";
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Code and markup must revalidate so an image update shows up immediately.
|
||||
location ~* \.(html|js|css|md)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Large, rarely-changing assets can be cached for a day.
|
||||
location ~* \.(woff2|png|svg|ico)$ {
|
||||
add_header Cache-Control "max-age=86400";
|
||||
}
|
||||
|
||||
# --- self-service backend (Auth47 submission API) ---
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 45s; # the connection gate + PayNym lookup probe Tor
|
||||
}
|
||||
|
||||
# SECURITY: the backend's own source and store (sessions, payment codes,
|
||||
# node API keys) live under server/ inside the web root. Never serve it.
|
||||
location ^~ /server/ { return 404; }
|
||||
|
||||
# Serve the SPA shell for the admin route (client-side view; auth is
|
||||
# enforced by the backend, this only returns the same HTML/JS).
|
||||
location = /admin { try_files /index.html =404; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env node
|
||||
// Bootstrap a new Dojo Bay from a TRUSTED existing instance, so a fresh
|
||||
// directory is mature the moment it starts: its nodes become approved store
|
||||
// records here and their reliability histories carry over.
|
||||
//
|
||||
// node scripts/bootstrap-import.mjs --onion <56-char>.onion \
|
||||
// --code PM8T... [--dry-run]
|
||||
//
|
||||
// Trust is verified before anything is imported: the remote instance's
|
||||
// data/operator.json must bind that onion to exactly the payment code YOU
|
||||
// typed in, under a valid wallet signature (server/crypto.ts). If the
|
||||
// signature does not verify, or binds a different onion or code, nothing is
|
||||
// fetched further. After that: dojos.json supplies the nodes, both history
|
||||
// files supply the record, and each PayNym is resolved against paynym.rs
|
||||
// (over Tor) for its full BIP47 code-variant set so imported operators can
|
||||
// sign in here with either variant. Existing ids are never touched; history
|
||||
// is only written for ids that have none.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { httpOverTor } from "./update.mjs";
|
||||
import { store, hasSignedBlock } from "../server/store.ts";
|
||||
import { verifySignedPayload, canonicalPairing } from "../server/crypto.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
|
||||
const defaultCfg = () => ({
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
});
|
||||
|
||||
// GET a JSON document from the remote instance over Tor.
|
||||
async function torFetchJSON(onionHost, urlPath, cfg, timeoutMs = 30000) {
|
||||
const req = `GET ${urlPath} HTTP/1.0\r\nHost: ${onionHost}\r\nUser-Agent: dojobay-bootstrap\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, onionHost, 80, req, timeoutMs);
|
||||
if (res.status !== 200) throw new Error(`${urlPath}: HTTP ${res.status || "no response"}`);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take; see server/build-public.ts. The
|
||||
// counter matters as well as the pid: one import writes the seed, both history
|
||||
// files and the avatars in quick succession.
|
||||
let tmpSeq = 0;
|
||||
async function writeJSONAtomic(p, obj) {
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
}
|
||||
|
||||
// fetchers are injectable for the self-test: fetchDoc(urlPath) -> object,
|
||||
// fetchCodes(paynymOrCode) -> [{code, segwit}, ...]
|
||||
/**
|
||||
* @param {{ onionHost?: string, trustedCode?: string, dryRun?: boolean, dataDir?: string,
|
||||
* log?: (...a: any[]) => void, fetchDoc?: any, fetchCodes?: any,
|
||||
* status?: "approved" | "pending" }} [opts]
|
||||
*/
|
||||
export async function bootstrapImport({
|
||||
onionHost, trustedCode, dryRun = false, dataDir = DATA_DIR, log = console.error,
|
||||
fetchDoc, fetchCodes, status = "approved",
|
||||
} = {}) {
|
||||
const cfg = defaultCfg();
|
||||
fetchDoc = fetchDoc || ((p) => torFetchJSON(onionHost, p, cfg));
|
||||
if (!fetchCodes) {
|
||||
const { fetchNymCodes } = await import("../server/paynym.mjs");
|
||||
fetchCodes = (nym) => fetchNymCodes(nym);
|
||||
}
|
||||
|
||||
// 1) trust gate: the remote operator binding must verify for THIS onion and
|
||||
// exactly the payment code the operator typed in.
|
||||
const { verifyOperatorDoc } = await import("../server/crypto.ts");
|
||||
const opDoc = await fetchDoc("/data/operator.json");
|
||||
const v = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` });
|
||||
if (!v.ok) throw new Error(`refusing to import: remote operator binding does not verify (${v.error})`);
|
||||
if (opDoc.paymentCode !== trustedCode) {
|
||||
throw new Error("refusing to import: the remote instance is operated by a DIFFERENT payment code than the one you trusted");
|
||||
}
|
||||
log(`trusted: ${onionHost} is signed by ${trustedCode.slice(0, 12)}… ✓`);
|
||||
|
||||
// 2) data
|
||||
const dojos = await fetchDoc("/data/dojos.json");
|
||||
const hist = await fetchDoc("/data/history.json").catch(() => ({ nodes: {} }));
|
||||
const daily = await fetchDoc("/data/history-daily.json").catch(() => ({ nodes: {} }));
|
||||
const nodes = (dojos.nodes || []).filter((n) => n.payload?.pairing?.url);
|
||||
|
||||
// The pairing URL identifies a physical Dojo; an id does not.
|
||||
//
|
||||
// An operator installing a new instance names their own node in the anchor,
|
||||
// then bootstraps from a directory that already lists it. The two ids differ,
|
||||
// because each instance derives one from the name it was given, so the same
|
||||
// machine arrived twice: once as the anchor and once as an import, with its
|
||||
// reliability history split between them. What is actually the same thing is
|
||||
// the onion address in the signed pairing payload, which is why matching on
|
||||
// it is not a heuristic. Two listings cannot share one, and an operator
|
||||
// cannot claim somebody else's without the signature failing.
|
||||
//
|
||||
// Compared as a whole URL rather than by host alone, because one machine may
|
||||
// legitimately serve mainnet at /v2 and testnet at /test/v2, and those are
|
||||
// two listings. Lower-cased and stripped of a trailing slash, since neither
|
||||
// changes which endpoint is meant.
|
||||
const pairingKey = (n) => {
|
||||
const u = n?.payload?.pairing?.url;
|
||||
if (typeof u !== "string" || !u) return null;
|
||||
return u.trim().toLowerCase().replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
// Everything this instance already lists, from the store AND from the seed
|
||||
// anchor. The anchor is not a store record, which is exactly why it was
|
||||
// invisible to this check and why the operator's own node was the one node
|
||||
// guaranteed to duplicate.
|
||||
const localByUrl = new Map();
|
||||
for (const r of await store.listSubmissions()) {
|
||||
const k = pairingKey(r);
|
||||
if (k) localByUrl.set(k, r.id);
|
||||
}
|
||||
try {
|
||||
const seed = JSON.parse(await readFile(path.join(dataDir, "seed.json"), "utf8"));
|
||||
for (const n of seed.nodes || []) {
|
||||
const k = pairingKey(n);
|
||||
if (k && !localByUrl.has(k)) localByUrl.set(k, n.id);
|
||||
}
|
||||
} catch { /* no anchor yet, which is normal on a bare install */ }
|
||||
|
||||
// 3) plan records: skip existing ids; resolve full code sets per PayNym
|
||||
const existingIds = new Set((await store.listSubmissions()).map((r) => r.id));
|
||||
const plan = [];
|
||||
const codeCache = new Map();
|
||||
for (const n of nodes) {
|
||||
if (existingIds.has(n.id)) { plan.push({ action: "skip", n }); continue; }
|
||||
// Same machine under a different id. The record is not created, because a
|
||||
// second listing for one Dojo is worse than a missing one, but the history
|
||||
// is worth having: it is the same node's record of itself, and dropping it
|
||||
// would restart an operator's reliability figures from nothing on a machine
|
||||
// that has been up for months. Carried onto the id this instance uses.
|
||||
const dupOf = localByUrl.get(pairingKey(n));
|
||||
if (dupOf) { plan.push({ action: "merge", n, dupOf }); continue; }
|
||||
// A published node from another instance carries its signed block in
|
||||
// dojos.json, so an unsigned one either predates the rule there or was
|
||||
// published by an instance that does not enforce it. Either way it cannot
|
||||
// enter this store, and saying so in the plan is better than a throw from
|
||||
// putSubmission half way through the import.
|
||||
if (!hasSignedBlock(n)) { plan.push({ action: "refuse", n, why: "no signed pairing block" }); continue; }
|
||||
// And the block must actually verify, here, against the payload it claims
|
||||
// to cover.
|
||||
//
|
||||
// hasSignedBlock only looks for the two header lines, and putSubmission
|
||||
// enforces nothing more, so until this check an imported listing's
|
||||
// signature was taken on the source instance's word: a directory that was
|
||||
// careless or compromised could publish a well-formed block that verifies
|
||||
// against nothing, and every instance bootstrapping from it would list the
|
||||
// node. This is the same standard the domain badges above are already held
|
||||
// to, and for the same reason: one compromised directory must not be able
|
||||
// to place listings across a federation.
|
||||
//
|
||||
// Offline and self-contained. canonicalPairing derives the message from the
|
||||
// payload being imported, so a payload altered in transit no longer matches
|
||||
// what was signed, and the addresses come from the payment code named
|
||||
// inside the block itself rather than from anything the source asserts.
|
||||
const sig = verifySignedPayload({
|
||||
signedText: n.signed,
|
||||
expectedMessage: canonicalPairing(n.payload),
|
||||
network: n.network === "testnet" ? "testnet" : "bitcoin",
|
||||
});
|
||||
if (!sig.ok) { plan.push({ action: "refuse", n, why: `signature does not verify (${sig.error})` }); continue; }
|
||||
let codes = n.paymentCode ? [n.paymentCode] : [];
|
||||
if (n.paynym) {
|
||||
if (!codeCache.has(n.paynym)) codeCache.set(n.paynym, await fetchCodes(n.paynym).catch(() => []));
|
||||
const all = codeCache.get(n.paynym).map((c) => c.code);
|
||||
if (all.length) codes = [...new Set([...all, ...codes])];
|
||||
}
|
||||
if (!codes.length) { plan.push({ action: "refuse", n, why: "no BIP47 payment code" }); continue; }
|
||||
plan.push({ action: "import", n, codes });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
for (const { action, n, codes, why } of plan) {
|
||||
log(` ${action.padEnd(6)} ${n.id.padEnd(28)} ${n.paynym || "(no PayNym)"} (${(codes || []).length} codes)${why ? " — " + why : ""}`);
|
||||
}
|
||||
const imports = plan.filter((p) => p.action === "import");
|
||||
const merges = plan.filter((p) => p.action === "merge");
|
||||
const refused = plan.filter((p) => p.action === "refuse");
|
||||
for (const m of merges) {
|
||||
log(` merge ${m.n.id.padEnd(28)} same Dojo as ${m.dupOf}: history only, no second listing`);
|
||||
}
|
||||
if (refused.length) log(`refused ${refused.length} node(s) that cannot be listed here: ${refused.map((p) => p.n.id).join(", ")}`);
|
||||
// The plan as data, not as log lines. The command line reads the log; the
|
||||
// admin console has to render this and let an operator decide, and parsing
|
||||
// the log back out would be inventing a format nobody agreed on.
|
||||
const rows = plan.map(({ action, n, codes, dupOf, why }) => ({
|
||||
action, id: n.id, name: n.name || n.id, network: n.network || null,
|
||||
paynym: n.paynym || null, url: n?.payload?.pairing?.url || null,
|
||||
codes: (codes || []).length, dupOf: dupOf || null, why: why || null,
|
||||
}));
|
||||
if (dryRun) {
|
||||
log(`dry run: ${imports.length} node(s) would be imported`
|
||||
+ (merges.length ? `, ${merges.length} recognised as already listed here` : "")
|
||||
+ ", nothing written.");
|
||||
return { imported: 0, planned: imports.length, merged: merges.length,
|
||||
refused: refused.length, plan: rows, status };
|
||||
}
|
||||
|
||||
for (const { n, codes } of imports) {
|
||||
await store.putSubmission({
|
||||
id: n.id, network: n.network, name: n.name || n.id,
|
||||
paymentCodes: codes, paynym: n.paynym || null,
|
||||
jurisdiction: n.jurisdiction || null, country: n.country || null,
|
||||
hardware: n.hardware || null, payload: n.payload,
|
||||
signed: n.signed || null,
|
||||
// approved at install, because choosing to bootstrap from a directory IS
|
||||
// the decision to trust its list. An import into a running instance
|
||||
// arrives pending instead, so it lands in the moderation queue the
|
||||
// operator already uses and nothing is published until they say so.
|
||||
status, source: `bootstrap-import:${onionHost}`,
|
||||
created_at: now, updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// 3b) verified operator domains.
|
||||
//
|
||||
// dojos.json publishes each badge's proof, and the signed statement is
|
||||
// deliberately portable: it names the domain and the payment code, never the
|
||||
// instance that verified it. So a claim travels intact — but it is NOT taken
|
||||
// on the source's word. We re-verify the signature here, locally and offline,
|
||||
// and store the claim UNVERIFIED so this instance's own sweep must see the TXT
|
||||
// record with its own eyes before any badge appears. Importing a badge because
|
||||
// another instance said so would make one compromised directory able to mint
|
||||
// verified domains across a federation.
|
||||
const claims = new Map();
|
||||
for (const n of dojos.nodes || []) {
|
||||
const pf = n.operator_domain_proof;
|
||||
if (!pf || !pf.domain || !pf.paymentCode || !pf.signed) continue;
|
||||
if (claims.has(pf.paymentCode)) continue;
|
||||
claims.set(pf.paymentCode, pf);
|
||||
}
|
||||
let domainsImported = 0, domainsRefused = 0;
|
||||
if (claims.size) {
|
||||
const { verifySignedUrlClaim } = await import("../server/crypto.ts");
|
||||
for (const [code, pf] of claims) {
|
||||
if (await store.getDomain(code)) continue; // never overwrite a local claim
|
||||
const v = verifySignedUrlClaim({ signed: pf.signed, expectedUrl: `https://${pf.domain}`, paymentCode: code });
|
||||
if (!v.ok) {
|
||||
log(` domain ${pf.domain}: refused (${v.error})`);
|
||||
domainsRefused++;
|
||||
continue;
|
||||
}
|
||||
await store.putDomain({
|
||||
paymentCode: code, domain: pf.domain, signed: pf.signed,
|
||||
verified: false, // this instance has not seen the DNS yet
|
||||
verified_at: null,
|
||||
last_check: null, // so the sweep picks it up immediately
|
||||
last_result: `imported from ${onionHost}; awaiting our own DNS check`,
|
||||
fail_since: null, created_at: now,
|
||||
});
|
||||
log(` domain ${pf.domain}: signature verified, awaiting our own TXT lookup`);
|
||||
domainsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) histories: only for ids we have no history for
|
||||
for (const [file, remote] of [["history.json", hist], ["history-daily.json", daily]]) {
|
||||
const p = path.join(dataDir, file);
|
||||
let local; try { local = JSON.parse(await readFile(p, "utf8")); } catch { local = { nodes: {} } }
|
||||
local.nodes = local.nodes || {};
|
||||
let added = 0;
|
||||
for (const [id, entry] of Object.entries(remote.nodes || {})) {
|
||||
if (!local.nodes[id] && imports.some((x) => x.n.id === id)) { local.nodes[id] = entry; added++; continue; }
|
||||
// A duplicate contributes its history under the id this instance uses.
|
||||
//
|
||||
// The two series are combined rather than one replacing the other. An
|
||||
// anchor installed an hour ago has a handful of checks of its own and the
|
||||
// remote has months: overwriting throws away the local ones, skipping
|
||||
// throws away the months, and neither is what an operator means by
|
||||
// importing history. Combined, de-duplicated on the timestamp, sorted,
|
||||
// and trimmed to the same window the updater keeps.
|
||||
const merged = merges.find((x) => x.n.id === id);
|
||||
if (!merged) continue;
|
||||
const key = entry.checks ? "checks" : "days";
|
||||
const stamp = key === "checks" ? "t" : "d";
|
||||
const mine = (local.nodes[merged.dupOf] || {})[key] || [];
|
||||
const theirs = entry[key] || [];
|
||||
if (!theirs.length) continue;
|
||||
const byStamp = new Map();
|
||||
// Local last, so a period this instance measured itself wins over the
|
||||
// remote's account of the same period.
|
||||
for (const row of [...theirs, ...mine]) if (row && row[stamp]) byStamp.set(row[stamp], row);
|
||||
const all = [...byStamp.values()].sort((x, y) => String(x[stamp]).localeCompare(String(y[stamp])));
|
||||
const cap = key === "checks" ? (remote.window_checks || local.window_checks || 144) : 90;
|
||||
local.nodes[merged.dupOf] = { [key]: all.slice(-cap) };
|
||||
added++;
|
||||
}
|
||||
if (added) {
|
||||
if (remote.interval_minutes && !local.interval_minutes) local.interval_minutes = remote.interval_minutes;
|
||||
if (remote.window_checks && !local.window_checks) local.window_checks = remote.window_checks;
|
||||
await writeJSONAtomic(p, local);
|
||||
log(` history: ${added} node(s) carried into ${file}`);
|
||||
}
|
||||
}
|
||||
log(`imported ${imports.length} node(s) from ${onionHost}`
|
||||
+ (merges.length ? `, and recognised ${merges.length} as node(s) this instance already lists` : "")
|
||||
+ ". Now run: node server/build-public.mjs");
|
||||
return { imported: imports.length, planned: imports.length, merged: merges.length,
|
||||
refused: refused.length, plan: rows, status,
|
||||
domains_imported: domainsImported, domains_refused: domainsRefused };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const arg = (k) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : null; };
|
||||
const onionHost = String(arg("--onion") || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
const trustedCode = arg("--code");
|
||||
if (!/^[a-z2-7]{56}\.onion$/.test(onionHost) || !trustedCode) {
|
||||
console.error("usage: node scripts/bootstrap-import.mjs --onion <56-char>.onion --code PM8T... [--dry-run]");
|
||||
process.exit(1);
|
||||
}
|
||||
bootstrapImport({ onionHost, trustedCode, dryRun: process.argv.includes("--dry-run") })
|
||||
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// Move seed nodes into the operator-managed store, idempotently.
|
||||
//
|
||||
// node scripts/migrate-seed-to-store.mjs --dry-run print the plan, write nothing
|
||||
// node scripts/migrate-seed-to-store.mjs apply it
|
||||
//
|
||||
// The seed's role is the instance ANCHOR: exactly one node, the instance
|
||||
// operator's own Dojo (mainnet or testnet), carrying their PayNym and BIP47
|
||||
// payment code. Everything else belongs in the store, where operators manage
|
||||
// their listings over Auth47. This script is the transition tool for an
|
||||
// instance whose seed still carries an old-style curated list:
|
||||
//
|
||||
// - a seed node with a PayNym present in data/paynym-codes.json becomes an
|
||||
// APPROVED store record owned by every BIP47 code variant of that PayNym
|
||||
// - a seed node WITHOUT a PayNym is REFUSED. Every listing must carry a BIP47
|
||||
// payment code: it is the identity a listing is owned, edited, verified and
|
||||
// recognised by. Code-less records were once adopted as admin-managed
|
||||
// exceptions; that door is closed, and the store refuses to write one.
|
||||
// - a seed node whose id already exists in the store is SKIPPED untouched,
|
||||
// which is what makes re-runs no-ops and lets the anchor node coexist as
|
||||
// both seed entry (bootstrap guarantee) and store record (Auth47-managed:
|
||||
// the store record shadows the seed copy in the public list)
|
||||
//
|
||||
// The script never rewrites data/seed.json: slimming the seed down to the
|
||||
// anchor is a deliberate, separate commit made AFTER the store records exist,
|
||||
// because a deploy that removes a node's seed entry before its store record
|
||||
// exists delists it (the history survives under the fourteen-day grace stamp,
|
||||
// but there is no reason to invite the gap).
|
||||
//
|
||||
// Record ids are the original seed ids, so reliability history (keyed by id)
|
||||
// carries over untouched. Afterwards run `node server/build-public.mjs`.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { store, hasSignedBlock } from "../server/store.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
const SEED_PATH = path.join(DATA_DIR, "seed.json");
|
||||
const CODES_PATH = path.join(DATA_DIR, "paynym-codes.json");
|
||||
const DRY = process.argv.includes("--dry-run");
|
||||
|
||||
async function readJSON(p, fallback) {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); }
|
||||
catch (e) { if (fallback !== undefined) return fallback; throw e; }
|
||||
}
|
||||
|
||||
const slugOf = (v) => String(v || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
|
||||
// Name derivation for owned groups. Remainder = seed id minus `${network}-`.
|
||||
// When one owner's several nodes share a first hyphen-token and stripping it
|
||||
// leaves something for each, drop the shared token; and prefer the seed's
|
||||
// display name whenever it slugs to the derived value, so capitalisation like
|
||||
// "wanderinKing072" survives.
|
||||
function deriveNames(nodes) {
|
||||
const rem = nodes.map((n) => n.id.replace(new RegExp(`^${n.network}-`), ""));
|
||||
let names = rem;
|
||||
if (nodes.length > 1) {
|
||||
const first = rem.map((r) => r.split("-")[0]);
|
||||
if (first.every((t) => t === first[0]) && rem.every((r) => r.includes("-"))) {
|
||||
names = rem.map((r) => r.split("-").slice(1).join("-"));
|
||||
}
|
||||
}
|
||||
return nodes.map((n, i) => (n.name && slugOf(n.name) === names[i]) ? n.name : names[i]);
|
||||
}
|
||||
|
||||
function toRecord(n, name, codes, now) {
|
||||
return {
|
||||
id: n.id, network: n.network, name,
|
||||
paymentCodes: codes,
|
||||
paynym: n.paynym || null,
|
||||
jurisdiction: n.jurisdiction || null,
|
||||
country: n.country || null,
|
||||
hardware: n.hardware || null,
|
||||
payload: n.payload,
|
||||
signed: n.signed || null,
|
||||
status: "approved",
|
||||
source: "seed-migration",
|
||||
created_at: now, updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seed = await readJSON(SEED_PATH);
|
||||
const mapping = (await readJSON(CODES_PATH, { mapping: {} })).mapping || {};
|
||||
const existing = await store.listSubmissions();
|
||||
const nodes = seed.nodes || [];
|
||||
|
||||
const owned = nodes.filter((n) => n.paynym);
|
||||
const missing = owned.filter((n) => !mapping[n.paynym]);
|
||||
if (missing.length) {
|
||||
console.error("aborting: no payment codes in", path.relative(ROOT, CODES_PATH), "for:");
|
||||
for (const n of missing) console.error(" ", n.id, n.paynym);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive names per owner; code-less nodes keep their seed name (or the id
|
||||
// remainder). Then refuse any per-network name collision against the plan
|
||||
// itself or records already in the store under a DIFFERENT id.
|
||||
const byOwner = new Map();
|
||||
for (const n of owned) (byOwner.get(n.paynym) || byOwner.set(n.paynym, []).get(n.paynym)).push(n);
|
||||
const nameOf = new Map();
|
||||
for (const group of byOwner.values()) deriveNames(group).forEach((nm, i) => nameOf.set(group[i].id, nm));
|
||||
for (const n of nodes.filter((x) => !x.paynym)) {
|
||||
const rem = n.id.replace(new RegExp(`^${n.network}-`), "");
|
||||
nameOf.set(n.id, (n.name && slugOf(n.name) === rem) ? n.name : (n.name || rem));
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const n of nodes) {
|
||||
const key = `${n.network}:${slugOf(nameOf.get(n.id))}`;
|
||||
if (seen.has(key)) { console.error("aborting: duplicate node name per network:", key); process.exit(1); }
|
||||
seen.add(key);
|
||||
}
|
||||
for (const r of existing) {
|
||||
for (const n of nodes) {
|
||||
if (r.id !== n.id && r.network === n.network && slugOf(r.name) === slugOf(nameOf.get(n.id))) {
|
||||
console.error(`aborting: seed node ${n.id} clashes with store record ${r.id} on name "${r.name}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const byId = new Map(existing.map((r) => [r.id, r]));
|
||||
const plan = nodes.map((n) => {
|
||||
if (byId.has(n.id)) return { action: "skip", why: "already in store (left untouched)", node: byId.get(n.id) };
|
||||
const codes = n.paynym ? mapping[n.paynym].codes.map((c) => c.code) : [];
|
||||
// Two things make a node unmigratable, and both are the store's rules
|
||||
// rather than this script's: no payment code means no owner, and no signed
|
||||
// pairing block means nothing a visitor can check. Refusing here rather
|
||||
// than letting putSubmission throw is what turns a stack trace part-way
|
||||
// through a migration into a plan you can read before anything is written.
|
||||
const node = toRecord(n, nameOf.get(n.id), codes, now);
|
||||
if (!codes.length) return { action: "refuse", why: "no BIP47 payment code", node };
|
||||
if (!hasSignedBlock(node)) return { action: "refuse", why: "no signed pairing block", node };
|
||||
return { action: "create", node };
|
||||
});
|
||||
|
||||
console.log(`${DRY ? "DRY RUN — " : ""}migration plan (${nodes.length} seed nodes):`);
|
||||
for (const { action, why, node } of plan) {
|
||||
const owner = node.paynym || "(no PayNym)";
|
||||
console.log(` ${action.padEnd(6)} ${node.id.padEnd(26)} name=${String(node.name).padEnd(18)} ${owner} (${(node.paymentCodes || []).length} codes)${why ? " — " + why : ""}`);
|
||||
if (action === "refuse") {
|
||||
console.log(` REFUSED: ${node.id} ${why}, so it cannot be migrated.`);
|
||||
console.log(` Give it a PayNym in data/paynym-codes.json and a signed pairing block, or drop it from the seed.`);
|
||||
}
|
||||
}
|
||||
|
||||
const changes = plan.filter((p) => p.action === "create");
|
||||
const refused = plan.filter((p) => p.action === "refuse");
|
||||
const tail = refused.length ? ` ${refused.length} refused: ${refused.map((p) => p.node.id).join(", ")}.` : "";
|
||||
if (DRY) { console.log(`\ndry run: ${changes.length} change(s) would be made, nothing written.${tail}`); return; }
|
||||
if (!changes.length) { console.log(`\nnothing to do: every seed node already has a store record.${tail}`); return; }
|
||||
for (const { node } of changes) await store.putSubmission(node);
|
||||
console.log(`\napplied ${changes.length} change(s).${tail} Now run: node server/build-public.mjs`);
|
||||
console.log("Once the store records exist, slim data/seed.json to the anchor (your own node) in a separate commit.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main().catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// Pack this instance's own codebase into data/dojobay-src.zip, so the running
|
||||
// site is its own distribution point: visitors download exactly the code the
|
||||
// instance runs (the footer's source icon), with no reliance on GitHub being
|
||||
// reachable. Node builtins only -- the ZIP container is written by hand
|
||||
// (deflate entries via zlib + a central directory), because a bare box has no
|
||||
// `zip` binary and scripts/ must run everywhere.
|
||||
//
|
||||
// node scripts/pack-source.mjs write data/dojobay-src.zip
|
||||
//
|
||||
// What goes in is manifest-driven, and what stays out matters more than what
|
||||
// goes in: NEVER the submission store (Dojo API keys, sessions), never the
|
||||
// instance's generated data (dojos.json, history, avatars), and never its
|
||||
// identity (seed.json anchor, operator.json binding, paynym-codes.json), so
|
||||
// extracting the zip over an existing web root upgrades the CODE and touches
|
||||
// nothing the instance owns. data/version.json IS included: it states which
|
||||
// commit the code is, which is exactly what a downloader wants to know.
|
||||
import { readFile, writeFile, rename, readdir, stat, mkdir } from "node:fs/promises";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PREFIX = "dojobay/"; // extraction lands in one folder
|
||||
|
||||
const INCLUDE_FILES = [
|
||||
"index.html", "manifest.json", "sw.js", "favicon.svg", "og-image.png",
|
||||
// LICENSE travels with THIRD-PARTY-NOTICES.md: the archive is a distributed
|
||||
// copy of the source, and the README it contains links to the notices.
|
||||
// SECURITY.md travels for the same reason: a recipient who finds a
|
||||
// vulnerability in this copy needs to be told where to send it.
|
||||
"LICENSE", "THIRD-PARTY-NOTICES.md", "README.md", "CONTRIBUTING.md", "SECURITY.md", "package.json",
|
||||
"tsconfig.json", "types.d.ts",
|
||||
"install.sh", "uninstall.sh",
|
||||
"data/version.json",
|
||||
];
|
||||
// docs/ holds the reasoning: why things are shaped as they are and what was
|
||||
// tried and rejected. It is the most useful thing in the tree to anyone
|
||||
// changing the code, and this archive is how a peer instance receives the code.
|
||||
const INCLUDE_DIRS = ["assets", "content", "deploy", "docs", "scripts", "server", ".github"];
|
||||
const DENY = [
|
||||
"server/data", "server/node_modules", "node_modules", ".git",
|
||||
"data/dojos.json", "data/history.json", "data/history-daily.json",
|
||||
"data/avatars", "data/seed.json", "data/operator.json", "data/paynym-codes.json",
|
||||
"data/updates", "data/backups",
|
||||
];
|
||||
const denied = (rel) => DENY.some((d) => rel === d || rel.startsWith(d + "/"))
|
||||
|| rel.endsWith(".zip") || path.basename(rel) === ".DS_Store";
|
||||
|
||||
async function collect(root) {
|
||||
const out = [];
|
||||
for (const f of INCLUDE_FILES) {
|
||||
try { await stat(path.join(root, f)); out.push(f); } catch { /* absent on this instance */ }
|
||||
}
|
||||
async function walk(rel) {
|
||||
for (const e of await readdir(path.join(root, rel), { withFileTypes: true })) {
|
||||
const r = rel + "/" + e.name;
|
||||
if (denied(r)) continue;
|
||||
if (e.isDirectory()) await walk(r);
|
||||
else if (e.isFile()) out.push(r);
|
||||
}
|
||||
}
|
||||
for (const d of INCLUDE_DIRS) {
|
||||
try { await stat(path.join(root, d)); await walk(d); } catch { /* absent */ }
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ---- minimal ZIP writer (PKZIP appnote: local headers + central directory) --
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
const crc32 = (buf) => {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const dosTime = (d) => (((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff);
|
||||
const dosDate = (d) => ((((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff);
|
||||
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n & 0xffff); return b; };
|
||||
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0); return b; };
|
||||
|
||||
function buildZip(entries) { // entries: [{name, data, mtime, mode}]
|
||||
const locals = [], centrals = [];
|
||||
let offset = 0;
|
||||
for (const { name, data, mtime, mode = 0o644 } of entries) {
|
||||
const nameBuf = Buffer.from(name, "utf8");
|
||||
const deflated = deflateRawSync(data, { level: 9 });
|
||||
const stored = deflated.length < data.length;
|
||||
const body = stored ? deflated : data;
|
||||
const method = stored ? 8 : 0;
|
||||
const crc = crc32(data);
|
||||
const t = u16(dosTime(mtime)), dt = u16(dosDate(mtime));
|
||||
const common = Buffer.concat([
|
||||
u16(20), u16(0x0800 /* UTF-8 names */), u16(method), t, dt,
|
||||
u32(crc), u32(body.length), u32(data.length), u16(nameBuf.length), u16(0),
|
||||
]);
|
||||
locals.push(Buffer.concat([u32(0x04034b50), common, nameBuf, body]));
|
||||
centrals.push(Buffer.concat([
|
||||
u32(0x02014b50), u16((3 << 8) | 20 /* unix */), common, u16(0), u16(0), u16(0),
|
||||
u32(((0o100000 | mode) >>> 0) * 0x10000) /* unix mode in high word */, u32(offset), nameBuf,
|
||||
]));
|
||||
offset += locals[locals.length - 1].length;
|
||||
}
|
||||
const cd = Buffer.concat(centrals);
|
||||
const end = Buffer.concat([
|
||||
u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length),
|
||||
u32(cd.length), u32(offset), u16(0),
|
||||
]);
|
||||
return Buffer.concat([...locals, cd, end]);
|
||||
}
|
||||
|
||||
export async function packSource({ root = ROOT, outDir = path.join(ROOT, "data") } = {}) {
|
||||
const files = await collect(root);
|
||||
const entries = [];
|
||||
for (const rel of files) {
|
||||
const p = path.join(root, rel);
|
||||
const [data, st] = [await readFile(p), await stat(p)];
|
||||
entries.push({ name: PREFIX + rel, data, mtime: st.mtime, mode: st.mode & 0o777 });
|
||||
}
|
||||
const zip = buildZip(entries);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const out = path.join(outDir, "dojobay-src.zip");
|
||||
await writeFile(out + ".tmp", zip);
|
||||
await rename(out + ".tmp", out);
|
||||
return { out, files: files.length, bytes: zip.length };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
packSource().then((r) => console.log(`wrote ${r.out}: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`))
|
||||
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — directory updater
|
||||
//
|
||||
// Probes every node's .onion pairing endpoint over Tor and rewrites the two
|
||||
// JSON databases the website reads:
|
||||
//
|
||||
// data/dojos.json current snapshot -> node.status + node.checked_at
|
||||
// data/history.json rolling history -> one {t, up} per node, per run
|
||||
//
|
||||
// dojos.json is also the source of truth for the node LIST. To add or remove a
|
||||
// node, edit dojos.json (name, paynym, payload, etc.); this script only fills
|
||||
// in status/checked_at and appends to the history. New nodes get a fresh
|
||||
// history series automatically; removed nodes are retired under a grace stamp
|
||||
// and only pruned HISTORY_GRACE_DAYS (default 14) after leaving the list.
|
||||
//
|
||||
// Health is checked through Tor's SOCKS5 proxy (no external npm deps). For a
|
||||
// node whose pairing payload carries an apikey, the check logs in to the Dojo
|
||||
// API and reads info.latest_block.height from GET /v2/wallet: the node is
|
||||
// "active" only if it returns a chain tip, which proves the whole stack (Tor,
|
||||
// nginx, Dojo API, bitcoind) is serving block data, and the height is recorded
|
||||
// on the node. Nodes without an apikey fall back to a plain HTTP reachability
|
||||
// probe (active if the onion returns an HTTP response line).
|
||||
//
|
||||
// Every Dojo response carries its running version in the X-Dojo-Version header;
|
||||
// the probe reads it and records node.detected_version, so a card can show the
|
||||
// live version rather than the one frozen into the pairing payload at signing
|
||||
// time. build-public.mjs decides the effective version an operator override
|
||||
// still wins over it.
|
||||
//
|
||||
// Run once (intended to be driven by cron/systemd every 10 minutes):
|
||||
// node scripts/update.mjs
|
||||
//
|
||||
// Config via environment variables (all optional):
|
||||
// TOR_SOCKS_HOST default 127.0.0.1
|
||||
// TOR_SOCKS_PORT default 9050
|
||||
// DATA_DIR default <repo>/data
|
||||
// TIMEOUT_MS default 45000 per-node Tor timeout
|
||||
// CONCURRENCY default 3 simultaneous Tor circuits
|
||||
// WINDOW_CHECKS default 144 history length kept per node (24h @ 10min)
|
||||
// RETENTION_DAYS default 90 daily-rollup days kept per node (~3 months)
|
||||
// CONNECT_ONLY default 0 "1" = treat a successful Tor connect as up
|
||||
// without waiting for an HTTP response line
|
||||
// DOJO_VERSION_HEADER default X-Dojo-Version response header carrying the
|
||||
// node's running Dojo version
|
||||
// =============================================================================
|
||||
|
||||
import net from "node:net";
|
||||
import { retireUnlisted } from "../server/build-public.ts";
|
||||
import { readFile, writeFile, rename, stat as fsStat, mkdir as fsMkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Chosen for a home connection as much as a VPS, because the unit that would
|
||||
// override them lives in /etc and no update can reach it. A node answering at
|
||||
// 23 seconds was being recorded as down against a 30 second ceiling, and six
|
||||
// circuits at once through one Tor client on a domestic line makes every probe
|
||||
// slow together, which reads as every node being down.
|
||||
export const DEFAULT_TIMEOUT_MS = 45000;
|
||||
export const DEFAULT_CONCURRENCY = 3;
|
||||
|
||||
const CFG = {
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
dataDir: process.env.DATA_DIR || path.resolve(__dirname, "..", "data"),
|
||||
timeoutMs: +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||
concurrency: +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY),
|
||||
windowChecks: +(process.env.WINDOW_CHECKS || 144),
|
||||
retentionDays: +(process.env.RETENTION_DAYS || 90),
|
||||
connectOnly: process.env.CONNECT_ONLY === "1",
|
||||
// The Dojo API stamps its running version on every response via this header
|
||||
// (Dojo's http-server appends X-Dojo-Version: <DOJO_VERSION_TAG> as global
|
||||
// middleware). Read it during the probe so a node's displayed version tracks
|
||||
// what it is actually running, instead of the value frozen into its pairing
|
||||
// payload at submission time. Overridable in case a fork renames the header.
|
||||
dojoVersionHeader: (process.env.DOJO_VERSION_HEADER || "X-Dojo-Version").toLowerCase(),
|
||||
};
|
||||
|
||||
// ---- SOCKS5 reply codes (RFC 1928 §6) ---------------------------------------
|
||||
const SOCKS_ERR = {
|
||||
0x01: "general failure",
|
||||
0x02: "connection not allowed",
|
||||
0x03: "network unreachable",
|
||||
0x04: "host unreachable", // Tor: onion descriptor not found / service down
|
||||
0x05: "connection refused",
|
||||
0x06: "TTL expired",
|
||||
0x07: "command not supported",
|
||||
0x08: "address type not supported",
|
||||
};
|
||||
|
||||
class SocksError extends Error {
|
||||
constructor(code) {
|
||||
super("SOCKS " + (SOCKS_ERR[code] || "error 0x" + code.toString(16)));
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Open a TCP stream to host:port THROUGH a SOCKS5 proxy (Tor), using a remote
|
||||
// hostname so the .onion is resolved by Tor, not locally. Resolves with a
|
||||
// connected socket on success; rejects on any handshake/connect failure.
|
||||
// -----------------------------------------------------------------------------
|
||||
export function socks5Connect(proxyHost, proxyPort, host, port, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(proxyPort, proxyHost);
|
||||
let stage = "greet";
|
||||
let buf = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
|
||||
const fail = (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
};
|
||||
const timer = setTimeout(() => fail(new Error("timeout")), timeoutMs);
|
||||
|
||||
socket.once("connect", () => {
|
||||
// greeting: VER=5, NMETHODS=1, METHOD=0 (no auth)
|
||||
socket.write(Buffer.from([0x05, 0x01, 0x00]));
|
||||
});
|
||||
socket.on("error", fail);
|
||||
socket.on("close", () => fail(new Error("proxy closed")));
|
||||
|
||||
socket.on("data", (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
|
||||
if (stage === "greet") {
|
||||
if (buf.length < 2) return;
|
||||
if (buf[0] !== 0x05 || buf[1] !== 0x00) return fail(new Error("proxy refused no-auth handshake"));
|
||||
buf = buf.subarray(2);
|
||||
stage = "reply";
|
||||
// CONNECT request with ATYP=3 (domain name), so Tor resolves the onion
|
||||
const hb = Buffer.from(host, "utf8");
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hb.length]),
|
||||
hb,
|
||||
Buffer.from([(port >> 8) & 0xff, port & 0xff]),
|
||||
]));
|
||||
}
|
||||
|
||||
if (stage === "reply") {
|
||||
if (buf.length < 4) return;
|
||||
if (buf[1] !== 0x00) return fail(new SocksError(buf[1]));
|
||||
const atyp = buf[3];
|
||||
const addrLen =
|
||||
atyp === 0x01 ? 4 :
|
||||
atyp === 0x04 ? 16 :
|
||||
atyp === 0x03 ? (buf.length >= 5 ? 1 + buf[4] : Infinity) : 0;
|
||||
if (buf.length < 4 + addrLen + 2) return; // wait for the full bound-addr
|
||||
// success: hand the live stream back to the caller
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.removeAllListeners("data");
|
||||
socket.removeAllListeners("error");
|
||||
socket.removeAllListeners("close");
|
||||
resolve(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Well-formed dummy extended keys, used only to elicit info.latest_block from
|
||||
// the Dojo /wallet endpoint. They are passed as `new` so the node performs no
|
||||
// rescan or historical import; they derive from a throwaway seed and can never
|
||||
// receive funds. One per network so the Dojo never rejects them on format.
|
||||
const DUMMY_XPUB = "xpub661MyMwAqRbcFhv1kNXxwyGrJUVPrmiBNTVDYAtpzF5zu9ceuhn5yV6oaSdveis14LSeBLzpWb58pDNN6hC59TTDyiN7iJR7kUQgXNMfZCL";
|
||||
const DUMMY_TPUB = "tpubD6NzVbkrYhZ4XW6sCZX49tcDdbb3rADEv65WtiwyL9qteSHMyvdB7vmdpUiiBDpErEyYnvWh3guBWPryVZ3K2tuX3K7RPq5MLS16HN9awey";
|
||||
|
||||
// The most bytes a response may accumulate before the read is abandoned.
|
||||
//
|
||||
// Every caller of httpOverTor is talking to a machine somebody else controls:
|
||||
// that is the point of the probe. Without a ceiling the reader accumulates
|
||||
// whatever arrives until the socket closes or the timeout fires, so a listed
|
||||
// node that simply never stops sending can push thirty seconds of Tor
|
||||
// throughput into the heap, times CONCURRENCY parallel probes, on a VPS whose
|
||||
// documented minimum is 1 GB. Nothing about that requires malice: a Dojo
|
||||
// misconfigured to return a file rather than JSON does it by accident.
|
||||
//
|
||||
// 2 MiB is chosen against the largest legitimate response any probe path sees,
|
||||
// which is a Dojo /wallet reply for two dummy xpubs, single-digit kilobytes.
|
||||
// A PayNym avatar is a small PNG and sits under the same ceiling comfortably;
|
||||
// it does not get a tighter limit of its own, because a second constant would
|
||||
// have to be kept in a sensible relationship with this one, and 2 MiB already
|
||||
// bounds the disk that syncAvatars can consume to a few tens of megabytes
|
||||
// across every listed code. The one caller that legitimately needs more is
|
||||
// self-update fetching a peer's source zip, and it passes its own value.
|
||||
export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// The unauthenticated probe reads only until it recognises an HTTP status line,
|
||||
// so it needs a far smaller ceiling than a full response: this bounds how long
|
||||
// it will listen to something that is not speaking HTTP at all.
|
||||
export const MAX_STATUS_LINE_BYTES = 64 * 1024;
|
||||
|
||||
// Send one HTTP/1.0 request over a fresh Tor stream and read the whole reply
|
||||
// (Connection: close means the server ends the body by closing). Resolves with
|
||||
// { status, body } or rejects on connect failure, read timeout, or a reply that
|
||||
// runs past maxBytes.
|
||||
export function httpOverTor(cfg, host, port, rawRequest, timeoutMs, maxBytes = MAX_RESPONSE_BYTES) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let socket;
|
||||
try {
|
||||
socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, timeoutMs);
|
||||
} catch (e) { return reject(e); }
|
||||
let buf = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(v); };
|
||||
const timer = setTimeout(() => done(reject, new Error("read-timeout")), timeoutMs);
|
||||
socket.on("data", (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
// Rejected the moment the ceiling is crossed rather than at close, so the
|
||||
// socket is destroyed and the memory released now. Waiting would mean a
|
||||
// node that never closes still occupies the full timeout while holding
|
||||
// everything it has sent. done() destroys the socket, so no further data
|
||||
// events arrive and the partial buffer goes out of scope with this call.
|
||||
if (buf.length > maxBytes) {
|
||||
done(reject, new Error(`response exceeded ${maxBytes} bytes`));
|
||||
}
|
||||
});
|
||||
socket.on("error", (e) => done(reject, e));
|
||||
socket.on("close", () => {
|
||||
const s = buf.toString("latin1");
|
||||
const m = s.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
const i = s.indexOf("\r\n\r\n");
|
||||
done(resolve, {
|
||||
status: m ? +m[1] : 0,
|
||||
body: i >= 0 ? s.slice(i + 4) : "",
|
||||
rawHead: i >= 0 ? s.slice(0, i + 2) : s, // headers incl. trailing CRLF
|
||||
bodyBuf: i >= 0 ? buf.subarray(i + 4) : Buffer.alloc(0), // exact bytes for binary payloads
|
||||
});
|
||||
});
|
||||
socket.write(rawRequest);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Dojo version from response headers -------------------------------------
|
||||
// The Dojo API sets its running version on every response (X-Dojo-Version). We
|
||||
// read it opportunistically while probing so the card can show the live value.
|
||||
// A node is only semi-trusted, so the value is validated and length-capped
|
||||
// before it can reach a data file: a version looks like 1, 1.28, 1.28.0 or
|
||||
// 1.28.0-rc1, with an optional leading v that we strip. Anything else -> null.
|
||||
export function normaliseVersion(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const v = raw.trim().replace(/^v/i, "").trim();
|
||||
if (!v || v.length > 32) return null;
|
||||
return /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/.test(v) ? v : null;
|
||||
}
|
||||
|
||||
// Pull the version out of a raw header block (the CRLF-joined header lines from
|
||||
// httpOverTor's rawHead, or the accumulated first bytes of a plain probe).
|
||||
// Header names are case-insensitive; the first occurrence wins.
|
||||
export function parseDojoVersion(rawHead, headerName = CFG.dojoVersionHeader) {
|
||||
if (typeof rawHead !== "string" || !rawHead) return null;
|
||||
const name = String(headerName).toLowerCase();
|
||||
for (const line of rawHead.split(/\r?\n/)) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx < 0) continue;
|
||||
if (line.slice(0, idx).trim().toLowerCase() !== name) continue;
|
||||
return normaliseVersion(line.slice(idx + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Electrum (indexer) endpoint from /support/services ---------------------
|
||||
// Dojo v1.27.0 added GET /support/services (ordinary apikey auth, not admin),
|
||||
// which returns { services: [ { type, kind, url }, … ] }. The "indexer" entry
|
||||
// is the node's Electrum server, published by the Dojo as
|
||||
// "<tcp|ssl>://<onion>:<port>" and present only when the operator exposes a
|
||||
// local indexer. Older Dojos have no such route, so absence is normal and is
|
||||
// reported as "not found" rather than an error.
|
||||
export function parseIndexerUrl(body) {
|
||||
let doc;
|
||||
try { doc = JSON.parse(body); } catch { return null; }
|
||||
const list = Array.isArray(doc?.services) ? doc.services : null;
|
||||
if (!list) return null;
|
||||
const hit = list.find((s) => s && s.type === "indexer" && typeof s.url === "string");
|
||||
return hit ? normaliseIndexerUrl(hit.url) : null;
|
||||
}
|
||||
|
||||
// A listed node is only semi-trusted, so the URL is validated and length-capped
|
||||
// before it can reach a data file or be rendered as a copyable string. Same
|
||||
// shape the card already accepts: tcp/ssl, v3 onion, explicit port.
|
||||
export function normaliseIndexerUrl(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const u = raw.trim();
|
||||
if (!u || u.length > 120) return null;
|
||||
return /^(tcp|ssl):\/\/[a-z2-7]{56}\.onion:\d{2,5}$/i.test(u) ? u : null;
|
||||
}
|
||||
|
||||
// ---- PayNym avatars ---------------------------------------------------------
|
||||
// Cards embed each node's PayNym avatar in the centre of its pairing QR. The
|
||||
// front end never fetches from third parties, so the avatar is mirrored here:
|
||||
// downloaded over Tor from the paynym.rs onion and served locally from
|
||||
// data/avatars/<paymentCode>.png. Missing files are fetched every cycle (which
|
||||
// also covers newly approved nodes within ten minutes) and existing ones are
|
||||
// refreshed weekly. Only verified PNG bytes are written; anything else -- an
|
||||
// error page, a redirect chain, an empty body -- is skipped without touching
|
||||
// the file, and failures are logged, never fatal.
|
||||
const PAYNYM_ONION = process.env.PAYNYM_ONION_HOST || "paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion";
|
||||
const AVATAR_MAX_AGE_MS = 7 * 86400000;
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
/**
|
||||
* @param {string} paymentCode
|
||||
* @param {{ proxyHost?: string, proxyPort?: number, destDir?: string,
|
||||
* timeoutMs?: number, host?: string, port?: number }} [opts]
|
||||
*/
|
||||
export async function fetchAvatar(paymentCode, { proxyHost, proxyPort, destDir, timeoutMs = 25000, host = PAYNYM_ONION, port = 80 } = {}) {
|
||||
const cfg = { proxyHost, proxyPort };
|
||||
let pathPart = `/${encodeURIComponent(paymentCode)}/avatar`;
|
||||
for (let hop = 0; hop < 2; hop++) { // follow at most one same-host redirect
|
||||
const req = `GET ${pathPart} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, host, port, req, timeoutMs);
|
||||
if ([301, 302, 307, 308].includes(res.status)) {
|
||||
const m = res.rawHead && res.rawHead.match(/\r\nlocation:\s*([^\r\n]+)/i);
|
||||
if (!m) throw new Error("redirect without location");
|
||||
const loc = m[1].trim();
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
const u = new URL(loc);
|
||||
if (u.hostname !== host) throw new Error("cross-host redirect");
|
||||
pathPart = u.pathname + u.search;
|
||||
} else pathPart = loc;
|
||||
continue;
|
||||
}
|
||||
if (res.status !== 200) throw new Error(`HTTP ${res.status || "no-response"}`);
|
||||
const bytes = res.bodyBuf || Buffer.from(res.body, "latin1");
|
||||
if (bytes.length < 8 || !bytes.subarray(0, 4).equals(PNG_MAGIC)) throw new Error("not a PNG");
|
||||
await fsMkdir(destDir, { recursive: true });
|
||||
const dest = path.join(destDir, `${paymentCode}.png`);
|
||||
const atmp = tmpName(dest);
|
||||
await writeFile(atmp, bytes);
|
||||
await rename(atmp, dest);
|
||||
return dest;
|
||||
}
|
||||
throw new Error("too many redirects");
|
||||
}
|
||||
|
||||
// Ensure a local avatar exists (and is reasonably fresh) for every listed
|
||||
// payment code. Small concurrency; per-code failures are logged and skipped.
|
||||
async function syncAvatars(nodes, destDir) {
|
||||
const codes = [...new Set(nodes.map((n) => n.paymentCode).filter(Boolean))];
|
||||
const wanted = [];
|
||||
for (const code of codes) {
|
||||
try {
|
||||
const st = await fsStat(path.join(destDir, `${code}.png`));
|
||||
if (Date.now() - st.mtimeMs < AVATAR_MAX_AGE_MS) continue;
|
||||
} catch { /* missing -> fetch */ }
|
||||
wanted.push(code);
|
||||
}
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const code = wanted[i++];
|
||||
if (!code) return;
|
||||
try {
|
||||
await fetchAvatar(code, { proxyHost: CFG.proxyHost, proxyPort: CFG.proxyPort, destDir });
|
||||
console.error(`[avatar] fetched ${code.slice(0, 12)}…`);
|
||||
} catch (e) {
|
||||
console.error(`[avatar] ${code.slice(0, 12)}…: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(3, wanted.length) }, worker));
|
||||
}
|
||||
|
||||
// Authenticated health check: log in with the node's apikey, then read the
|
||||
// chain tip from GET /v2/wallet. The Dojo stamps X-Dojo-Version on every
|
||||
// response, so we harvest it from the first response that carries it (the login
|
||||
// reply always does) even on an otherwise-down cycle. Returns
|
||||
// { up, reason, ms, height?, blockTime?, detectedVersion? }.
|
||||
async function probeHeight(url, cfg) {
|
||||
const t0 = Date.now();
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
const port = u.port ? +u.port : 80;
|
||||
const base = (u.pathname || "/v2").replace(/\/+$/, "") || "/v2"; // e.g. /v2
|
||||
const dummy = cfg.network === "testnet" ? DUMMY_TPUB : DUMMY_XPUB;
|
||||
let detectedVersion = null;
|
||||
|
||||
// 1) login -> access token
|
||||
let token;
|
||||
try {
|
||||
const body = `apikey=${encodeURIComponent(cfg.apikey)}`;
|
||||
const req =
|
||||
`POST ${base}/auth/login HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Content-Type: application/x-www-form-urlencoded\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` +
|
||||
`User-Agent: dojobay-checker\r\nConnection: close\r\n\r\n${body}`;
|
||||
const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs);
|
||||
detectedVersion = parseDojoVersion(res.rawHead, cfg.dojoVersionHeader) || detectedVersion;
|
||||
if (res.status !== 200) return { up: false, reason: `login HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion };
|
||||
token = JSON.parse(res.body)?.authorizations?.access_token;
|
||||
if (!token) return { up: false, reason: "login: no token", ms: Date.now() - t0, detectedVersion };
|
||||
} catch (e) {
|
||||
return { up: false, reason: "login: " + e.message, ms: Date.now() - t0, detectedVersion };
|
||||
}
|
||||
|
||||
// 2) wallet -> info.latest_block.height
|
||||
try {
|
||||
const q = `active=${dummy}&new=${dummy}`;
|
||||
const req =
|
||||
`GET ${base}/wallet?${q} HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs);
|
||||
detectedVersion = detectedVersion || parseDojoVersion(res.rawHead, cfg.dojoVersionHeader);
|
||||
if (res.status !== 200) return { up: false, reason: `wallet HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion };
|
||||
const info = JSON.parse(res.body)?.info?.latest_block;
|
||||
const height = info?.height;
|
||||
if (typeof height !== "number") return { up: false, reason: "wallet: no block height", ms: Date.now() - t0, detectedVersion };
|
||||
|
||||
// 3) services -> Electrum (indexer) endpoint. Best-effort and strictly
|
||||
// additive: the node is already known up, so a missing route (pre-1.27.0),
|
||||
// a node that exposes no indexer, or any error here must never downgrade
|
||||
// the result. Absence simply means the card shows N/A.
|
||||
let detectedIndexer = null;
|
||||
try {
|
||||
const sreq =
|
||||
`GET ${base}/support/services HTTP/1.0\r\nHost: ${host}\r\n` +
|
||||
`Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`;
|
||||
const sres = await httpOverTor(cfg, host, port, sreq, cfg.timeoutMs);
|
||||
detectedVersion = detectedVersion || parseDojoVersion(sres.rawHead, cfg.dojoVersionHeader);
|
||||
if (sres.status === 200) detectedIndexer = parseIndexerUrl(sres.body);
|
||||
} catch { /* leave null */ }
|
||||
|
||||
return { up: true, reason: "height", height, blockTime: info.time ?? null, ms: Date.now() - t0, detectedVersion, detectedIndexer };
|
||||
} catch (e) {
|
||||
return { up: false, reason: "wallet: " + e.message, ms: Date.now() - t0, detectedVersion };
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Probe a single onion URL. Returns { up, reason, ms }.
|
||||
// up = Tor connected AND (CONNECT_ONLY, or an HTTP status line came back)
|
||||
// -----------------------------------------------------------------------------
|
||||
// Fill in the transport settings a probe cannot work without. Callers pass a
|
||||
// partial config (an apikey and a network, say) and it is easy to forget to
|
||||
// spread PROBE_CFG or CFG alongside it; without these, net.connect is handed an
|
||||
// undefined port and Node reports 'The "options" or "port" or "path" argument
|
||||
// must be specified', which says nothing about the real mistake. The defaults
|
||||
// are the same ones PROBE_CFG uses, so a partial config now behaves rather than
|
||||
// failing obscurely. Explicitly supplied values always win.
|
||||
/**
|
||||
* @param {Partial<import("../types.js").ProbeCfg>} [cfg]
|
||||
* @returns {import("../types.js").ProbeCfg}
|
||||
*/
|
||||
export function probeCfg(cfg = {}) {
|
||||
return {
|
||||
...cfg,
|
||||
proxyHost: cfg.proxyHost ?? (process.env.TOR_SOCKS_HOST || "127.0.0.1"),
|
||||
proxyPort: cfg.proxyPort ?? +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
// Same default as CFG below, from one place. These were separate literals
|
||||
// and had already diverged: the cron path waited 45 seconds while anything
|
||||
// going through this helper waited 30, so the same node could be up for one
|
||||
// caller and down for the other.
|
||||
timeoutMs: cfg.timeoutMs ?? +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||
concurrency: cfg.concurrency ?? +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {Partial<import("../types.js").ProbeCfg>} [cfgIn]
|
||||
* @returns {Promise<import("../types.js").ProbeResult>}
|
||||
*/
|
||||
export async function probe(url, cfgIn = CFG) {
|
||||
const cfg = probeCfg(cfgIn);
|
||||
// Preferred path: authenticated chain-tip check when an apikey is available.
|
||||
if (cfg.apikey) return probeHeight(url, cfg);
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
const port = u.port ? +u.port : (u.protocol === "https:" ? 443 : 80);
|
||||
const reqPath = (u.pathname || "/") + (u.search || "");
|
||||
const t0 = Date.now();
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, cfg.timeoutMs);
|
||||
} catch (e) {
|
||||
return { up: false, reason: e.message, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
// TLS onions or connect-only mode: a successful Tor stream is the signal.
|
||||
if (cfg.connectOnly || u.protocol === "https:") {
|
||||
socket.destroy();
|
||||
return { up: true, reason: u.protocol === "https:" ? "tls-connect" : "connect", ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
// Otherwise confirm the Dojo HTTP server actually answers.
|
||||
return await new Promise((resolve) => {
|
||||
let got = "";
|
||||
let settled = false;
|
||||
const finish = (up, reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
// A code-less node has no apikey, so this is the only chance to read its
|
||||
// version; the header rides in the same first packet as the status line
|
||||
// often enough to be worth a look. Absent -> null, harmless.
|
||||
resolve({ up, reason, ms: Date.now() - t0, detectedVersion: parseDojoVersion(got, cfg.dojoVersionHeader) });
|
||||
};
|
||||
const timer = setTimeout(() => finish(got.length > 0, got ? "partial" : "read-timeout"), cfg.timeoutMs);
|
||||
|
||||
socket.on("data", (d) => {
|
||||
got += d.toString("latin1");
|
||||
if (/^HTTP\//i.test(got)) finish(true, "http");
|
||||
// The same unbounded accumulation httpOverTor had, reached by a different
|
||||
// door. A well-behaved server puts its status line in the first packet
|
||||
// and the test above ends the read immediately, but a node that sends
|
||||
// anything NOT starting with "HTTP/" is never matched, so before this
|
||||
// guard `got` grew until the timeout with no ceiling at all. A status
|
||||
// line is a few dozen bytes; 64 KiB without one means this is not an HTTP
|
||||
// server, which is the answer the probe wanted anyway.
|
||||
else if (got.length > MAX_STATUS_LINE_BYTES) finish(false, "no-http-response");
|
||||
});
|
||||
socket.on("error", () => finish(got.length > 0, "socket-error"));
|
||||
socket.on("close", () => finish(got.length > 0, "closed"));
|
||||
|
||||
socket.write(
|
||||
`HEAD ${reqPath} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- date helpers (UTC, matching the formats already in the JSON) -----------
|
||||
const p2 = (n) => String(n).padStart(2, "0");
|
||||
function stamps(d = new Date()) {
|
||||
const Y = d.getUTCFullYear(), M = p2(d.getUTCMonth() + 1), D = p2(d.getUTCDate());
|
||||
const h = p2(d.getUTCHours()), m = p2(d.getUTCMinutes()), s = p2(d.getUTCSeconds());
|
||||
return {
|
||||
isoSec: `${Y}-${M}-${D}T${h}:${m}:${s}Z`, // generated_at
|
||||
isoMin: `${Y}-${M}-${D}T${h}:${m}Z`, // history check timestamp
|
||||
dateTime: `${Y}-${M}-${D} ${h}:${m}:${s}`, // node.checked_at
|
||||
};
|
||||
}
|
||||
|
||||
// ---- small concurrency pool -------------------------------------------------
|
||||
async function pool(items, limit, fn) {
|
||||
const out = new Array(items.length);
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
out[idx] = await fn(items[idx], idx);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJSON(file, fallback) {
|
||||
try { return JSON.parse(await readFile(file, "utf8")); }
|
||||
catch (e) { if (e.code === "ENOENT" && fallback !== undefined) return fallback; throw e; }
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take.
|
||||
//
|
||||
// Every atomic write here was `<file>.tmp`, which is not atomic between
|
||||
// processes: two writers produce the same path, the first rename consumes it,
|
||||
// and the second fails with ENOENT on a file it had just written. That is not
|
||||
// hypothetical. The installer enables the update timer and then runs its own
|
||||
// first probe cycle, and once the timer gained a calendar schedule with
|
||||
// Persistent=true, enabling it fired a catch-up run immediately rather than
|
||||
// after two minutes. Two updaters wrote data/dojos.json.tmp at once and the
|
||||
// install ended by announcing failures on a directory that was already
|
||||
// updating.
|
||||
//
|
||||
// The pid and a counter are enough: the collision is between processes on one
|
||||
// machine, and the rename is what makes the swap atomic for readers.
|
||||
function tmpName(file) {
|
||||
return `${file}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
}
|
||||
let tmpSeq = 0;
|
||||
|
||||
// Write atomically: a reader (the website) never sees a half-written file.
|
||||
async function writeJSONAtomic(file, obj) {
|
||||
const tmp = tmpName(file);
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, file);
|
||||
}
|
||||
|
||||
// Merge seed + approved submissions into the public list (delegates to
|
||||
// server/build-public.mjs, which preserves live statuses and histories).
|
||||
// Exported so the self-test can drive it against isolated data directories.
|
||||
export async function reconcilePublicList() {
|
||||
if (!process.env.PUBLIC_DATA_DIR) process.env.PUBLIC_DATA_DIR = CFG.dataDir;
|
||||
const { rebuild } = await import("../server/build-public.ts");
|
||||
return rebuild();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
async function main() {
|
||||
const dojosPath = path.join(CFG.dataDir, "dojos.json");
|
||||
// Reconcile FIRST: fold the curated seed and every APPROVED submission into
|
||||
// dojos.json before this cycle reads it. The admin approve does its own
|
||||
// rebuild, but that write is lost if it lands while a probe cycle (minutes
|
||||
// long over Tor) is in flight, because the cycle writes back the node list
|
||||
// it read at the start. Rebuilding here means an approved node can be absent
|
||||
// for at most one cycle, never indefinitely.
|
||||
try {
|
||||
const r = await reconcilePublicList();
|
||||
console.error(`[reconcile] ${r.msg}`);
|
||||
} catch (e) {
|
||||
console.error(`[reconcile] skipped: ${e.message}`);
|
||||
}
|
||||
const historyPath = path.join(CFG.dataDir, "history.json");
|
||||
|
||||
const dojos = await readJSON(dojosPath);
|
||||
if (!dojos || !Array.isArray(dojos.nodes)) throw new Error(`bad or missing ${dojosPath}`);
|
||||
// Keep the self-hosted source download current: regenerate the zip when it
|
||||
// is missing or older than data/version.json (i.e. after any code deploy).
|
||||
try {
|
||||
const zipPath = path.join(CFG.dataDir, "dojobay-src.zip");
|
||||
const verPath = path.join(CFG.dataDir, "version.json");
|
||||
const zipSt = await fsStat(zipPath).catch(() => null);
|
||||
const verSt = await fsStat(verPath).catch(() => null);
|
||||
if (!zipSt || (verSt && verSt.mtimeMs > zipSt.mtimeMs)) {
|
||||
const { packSource } = await import("./pack-source.mjs");
|
||||
const r = await packSource({ outDir: CFG.dataDir });
|
||||
console.error(`[src-zip] repacked: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`);
|
||||
}
|
||||
} catch (e) { console.error(`[src-zip] skipped: ${e.message}`); }
|
||||
|
||||
// Mirror PayNym avatars for every listed code (non-blocking for the probes).
|
||||
const operatorDoc = await readJSON(path.join(CFG.dataDir, "operator.json")).catch(() => null) ?? {};
|
||||
const avatarSubjects = dojos.nodes.concat(operatorDoc.paymentCode ? [{ paymentCode: operatorDoc.paymentCode }] : []);
|
||||
const avatarsDone = syncAvatars(avatarSubjects, path.join(CFG.dataDir, "avatars")).catch((e) => console.error("[avatar]", e.message));
|
||||
const history = await readJSON(historyPath, { interval_minutes: 10, window_checks: CFG.windowChecks, nodes: {} });
|
||||
const window = history.window_checks || CFG.windowChecks;
|
||||
|
||||
const now = new Date();
|
||||
const ts = stamps(now);
|
||||
console.error(`[${ts.isoSec}] probing ${dojos.nodes.length} nodes via socks5h://${CFG.proxyHost}:${CFG.proxyPort} (timeout ${CFG.timeoutMs}ms, concurrency ${CFG.concurrency})`);
|
||||
|
||||
const results = await pool(dojos.nodes, CFG.concurrency, async (n) => {
|
||||
const url = n?.payload?.pairing?.url;
|
||||
if (!url) return { up: false, reason: "no pairing url", ms: 0 };
|
||||
return probe(url, { ...CFG, apikey: n?.payload?.pairing?.apikey, network: n.network });
|
||||
});
|
||||
|
||||
// ---- did this cycle learn anything? ----
|
||||
//
|
||||
// Fifteen independently operated nodes on different continents do not fail in
|
||||
// the same ten-minute window. When every one of them fails, the cause is here:
|
||||
// Tor rebuilding circuits after a suspend, a home connection renegotiating,
|
||||
// the daemon restarted underneath us. Recording that would write a DOWN check
|
||||
// against every operator in the directory and pull down reliability figures
|
||||
// this instance publishes about other people's machines, for a fault of its
|
||||
// own. So it is not recorded.
|
||||
//
|
||||
// The threshold is zero rather than a proportion. A cycle where some nodes
|
||||
// answer proves the local path works, and the ones that did not answer really
|
||||
// did not; only a clean sweep is evidence about this machine instead of about
|
||||
// them. A directory with one listing would trip this on a genuine outage, and
|
||||
// that is the right trade: withholding one node's bad cycle costs far less
|
||||
// than publishing a false one against everybody.
|
||||
const allFailed = dojos.nodes.length > 0 && results.every((r) => !r.up);
|
||||
|
||||
// ---- update current snapshot ----
|
||||
let up = 0;
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const r = results[i];
|
||||
if (r.up) up++;
|
||||
n.status = r.up ? "active" : "inactive";
|
||||
n.checked_at = ts.dateTime;
|
||||
// Record the tip height when we read one; keep the last known height on a
|
||||
// down cycle so the card can still show where the node last was.
|
||||
if (typeof r.height === "number") n.block_height = r.height;
|
||||
else if (!("block_height" in n)) n.block_height = null;
|
||||
// Same sticky rule for the version read from X-Dojo-Version: update it when
|
||||
// this cycle saw one, otherwise leave the last known value in place. The
|
||||
// effective card version (operator override > detected > pairing default)
|
||||
// is computed by build-public.mjs, which carries this field across the
|
||||
// reconcile rebuild that opens every cycle.
|
||||
if (r.detectedVersion) n.detected_version = r.detectedVersion;
|
||||
else if (!("detected_version" in n)) n.detected_version = null;
|
||||
// Same sticky rule for the Electrum endpoint read from /support/services:
|
||||
// keep the last known value when a cycle didn't read one, so a node that is
|
||||
// merely down for a cycle doesn't flip its card to N/A. build-public.mjs
|
||||
// computes the published value and carries this field across the rebuild.
|
||||
if (r.detectedIndexer) n.detected_indexer = r.detectedIndexer;
|
||||
else if (!("detected_indexer" in n)) n.detected_indexer = null;
|
||||
});
|
||||
dojos.interval_minutes = dojos.interval_minutes || 10;
|
||||
|
||||
if (allFailed) {
|
||||
// Publish the fault and nothing else. Statuses, heights and checked_at stay
|
||||
// as the last cycle that actually reached something left them, and
|
||||
// generated_at is deliberately not advanced, so the staleness banner keeps
|
||||
// measuring the age of real data rather than the age of a failure.
|
||||
const fresh = await readJSON(dojosPath, null);
|
||||
if (fresh) {
|
||||
fresh.probe_fault = { at: ts.isoSec, nodes: dojos.nodes.length };
|
||||
await writeJSONAtomic(dojosPath, fresh);
|
||||
}
|
||||
console.error(`[${ts.isoSec}] every one of ${dojos.nodes.length} nodes failed, which is`
|
||||
+ " almost certainly a fault here rather than all of them at once.");
|
||||
console.error(" Nothing was recorded: no statuses changed and no history written.");
|
||||
console.error(" Check Tor on this machine (systemctl status tor@default), and the clock.");
|
||||
return;
|
||||
}
|
||||
dojos.generated_at = ts.isoSec;
|
||||
delete dojos.probe_fault;
|
||||
|
||||
// ---- update rolling history (append + trim, retire stale ids) ----
|
||||
const listed = new Set(dojos.nodes.map((n) => n.id));
|
||||
const histNodes = {};
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const prev = (history.nodes?.[n.id]?.checks) || [];
|
||||
const checks = prev.concat([{ t: ts.isoMin, up: results[i].up }]);
|
||||
if (checks.length > window) checks.splice(0, checks.length - window);
|
||||
histNodes[n.id] = { checks };
|
||||
});
|
||||
// Unlisted ids are kept under a `retired` stamp for HISTORY_GRACE_DAYS (same
|
||||
// rule as build-public.mjs), so a bad or transient node list cannot destroy
|
||||
// accumulated history; a resurrected id resumes where it left off.
|
||||
for (const id of Object.keys(history.nodes || {})) if (!histNodes[id]) histNodes[id] = history.nodes[id];
|
||||
retireUnlisted(histNodes, (id) => listed.has(id), ts.isoSec);
|
||||
|
||||
await writeJSONAtomic(dojosPath, dojos);
|
||||
await writeJSONAtomic(historyPath, {
|
||||
generated_at: ts.isoSec,
|
||||
interval_minutes: history.interval_minutes || 10,
|
||||
window_checks: window,
|
||||
nodes: histNodes,
|
||||
});
|
||||
|
||||
// ---- update 90-day daily rollup (per-day uptime + closing block height) ----
|
||||
// One record per node per UTC day; `close` is the last height read that day,
|
||||
// so at day's end it holds the closing height. Retained RETENTION_DAYS days.
|
||||
const dailyPath = path.join(CFG.dataDir, "history-daily.json");
|
||||
const daily = await readJSON(dailyPath, { retention_days: CFG.retentionDays, nodes: {} });
|
||||
const today = ts.dateTime.slice(0, 10); // YYYY-MM-DD (UTC)
|
||||
const dailyNodes = {};
|
||||
dojos.nodes.forEach((n, i) => {
|
||||
const r = results[i];
|
||||
const days = ((daily.nodes?.[n.id]?.days) || []).map((d) => ({ ...d }));
|
||||
let rec = days.length && days[days.length - 1].d === today ? days[days.length - 1] : null;
|
||||
if (!rec) { rec = { d: today, up: 0, total: 0, pct: 0, close: null }; days.push(rec); }
|
||||
rec.total += 1;
|
||||
if (r.up) rec.up += 1;
|
||||
rec.pct = Math.round((rec.up / rec.total) * 1000) / 10;
|
||||
if (typeof r.height === "number") rec.close = r.height;
|
||||
if (days.length > CFG.retentionDays) days.splice(0, days.length - CFG.retentionDays);
|
||||
dailyNodes[n.id] = { days };
|
||||
});
|
||||
for (const id of Object.keys(daily.nodes || {})) if (!dailyNodes[id]) dailyNodes[id] = daily.nodes[id];
|
||||
retireUnlisted(dailyNodes, (id) => listed.has(id), ts.isoSec);
|
||||
await writeJSONAtomic(dailyPath, {
|
||||
generated_at: ts.isoSec,
|
||||
retention_days: CFG.retentionDays,
|
||||
nodes: dailyNodes,
|
||||
});
|
||||
|
||||
// ---- probe PENDING submissions so the operator sees uptime before approving
|
||||
// Results are written server-side only (server/data/pending-probe.json), never
|
||||
// to the public data/, so an unapproved submission is not exposed over Tor.
|
||||
try {
|
||||
const { store } = await import("../server/store.ts");
|
||||
const serverDataDir = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "server", "data");
|
||||
const pendingPath = path.join(serverDataDir, "pending-probe.json");
|
||||
const subs = (await store.listSubmissions()).filter((s) => s.status === "pending");
|
||||
if (subs.length) {
|
||||
const prevDoc = await readJSON(pendingPath, { window_checks: window, nodes: {} });
|
||||
const presults = await pool(subs, CFG.concurrency, async (s) => {
|
||||
const url = s?.payload?.pairing?.url;
|
||||
if (!url) return { up: false, reason: "no pairing url", ms: 0 };
|
||||
return probe(url, { ...CFG, apikey: s?.payload?.pairing?.apikey, network: s.network });
|
||||
});
|
||||
const pnodes = {};
|
||||
subs.forEach((s, i) => {
|
||||
const r = presults[i];
|
||||
const prev = (prevDoc.nodes?.[s.id]?.checks) || [];
|
||||
const checks = prev.concat([{ t: ts.isoMin, up: r.up }]);
|
||||
if (checks.length > window) checks.splice(0, checks.length - window);
|
||||
pnodes[s.id] = {
|
||||
status: r.up ? "active" : "inactive",
|
||||
checked_at: ts.dateTime,
|
||||
block_height: typeof r.height === "number" ? r.height
|
||||
: (prevDoc.nodes?.[s.id]?.block_height ?? null),
|
||||
detected_version: r.detectedVersion || (prevDoc.nodes?.[s.id]?.detected_version ?? null),
|
||||
detected_indexer: r.detectedIndexer || (prevDoc.nodes?.[s.id]?.detected_indexer ?? null),
|
||||
checks,
|
||||
};
|
||||
});
|
||||
await writeJSONAtomic(pendingPath, { generated_at: ts.isoSec, window_checks: window, nodes: pnodes });
|
||||
console.error(`[${ts.isoSec}] probed ${subs.length} pending submission(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${ts.isoSec}] pending probe skipped: ${e.message}`);
|
||||
}
|
||||
|
||||
console.error(`[${ts.isoSec}] done: ${up}/${dojos.nodes.length} active`);
|
||||
for (const [i, n] of dojos.nodes.entries()) {
|
||||
const r = results[i];
|
||||
console.error(` ${r.up ? "UP " : "DOWN"} ${n.id.padEnd(28)} ${String(r.ms).padStart(6)}ms ${r.reason || ""}`);
|
||||
}
|
||||
await avatarsDone; // let in-flight avatar mirrors finish before the timer unit exits
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main().catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Maintainer moderation CLI (run on the server by a maintainer over SSH).
|
||||
// node admin.mjs list show pending/approved/rejected
|
||||
// node admin.mjs approve <id> [paynym] approve a submission (optionally set its PayNym)
|
||||
// node admin.mjs reject <id> reject a submission
|
||||
// node admin.mjs remove <id> delete a submission outright
|
||||
// After approving/rejecting, run build-public.mjs to regenerate the public list.
|
||||
import { store } from "./store.ts";
|
||||
import { resolvePayNym } from "./paynym.mjs";
|
||||
|
||||
const [cmd, id, extra] = process.argv.slice(2);
|
||||
|
||||
function line(r) {
|
||||
return `${r.status.padEnd(8)} ${r.id.padEnd(26)} ${r.network.padEnd(7)} ${(r.paynym || "-").padEnd(18)} ${(r.name || "-").padEnd(18)} ${r.payload?.pairing?.url || ""}`;
|
||||
}
|
||||
|
||||
const cmds = {
|
||||
async list() {
|
||||
const subs = await store.listSubmissions();
|
||||
if (!subs.length) return console.log("(no submissions)");
|
||||
for (const r of subs.sort((a, b) => (a.status).localeCompare(b.status))) console.log(line(r));
|
||||
},
|
||||
async approve() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "approved";
|
||||
if (extra) {
|
||||
r.paynym = extra.startsWith("+") ? extra : "+" + extra; // maintainer override
|
||||
} else if (!r.paynym) {
|
||||
const resolved = await resolvePayNym((r.paymentCodes || [])[0]).catch(() => null);
|
||||
if (resolved) r.paynym = resolved;
|
||||
}
|
||||
r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("approved:", id, "paynym:", r.paynym || "(none set — pass one as the 3rd arg)");
|
||||
console.log("now run: node build-public.mjs");
|
||||
},
|
||||
async reject() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "rejected"; r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("rejected:", id, "(run build-public.mjs to drop it from the public list)");
|
||||
},
|
||||
async remove() {
|
||||
await store.deleteSubmission(id);
|
||||
console.log("removed:", id);
|
||||
},
|
||||
};
|
||||
|
||||
(cmds[cmd] || (async () => { console.log("usage: node admin.mjs [list|approve <id> [paynym]|reject <id>|remove <id>]"); }))()
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => { console.error("error:", e.message); process.exit(1); });
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — apply operator-signed pairing payload updates.
|
||||
//
|
||||
// Takes signed blocks an operator has sent out of band (a re-signed pairing
|
||||
// payload, a new apikey, a moved onion) and applies them to the store, doing
|
||||
// exactly what the submission gate would have done had they gone through the
|
||||
// site:
|
||||
//
|
||||
// 1. the block must parse, and its signature must be valid over its own text;
|
||||
// 2. the BIP47 code inside the signed text must derive the signing address;
|
||||
// 3. that code must already own a record here, which is how the update is
|
||||
// matched to a listing;
|
||||
// 4. the payload written is the one INSIDE the signed block, so what is
|
||||
// published is exactly what the operator attested to.
|
||||
//
|
||||
// The record's id is never changed, so its reliability history survives. Status
|
||||
// is left alone: an approved listing stays approved, a pending one stays pending.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node apply-signed-payload.ts blocks/*.txt # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node apply-signed-payload.ts --apply blocks/*.txt
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node audit-signed.mjs
|
||||
//
|
||||
// Each file holds one signed block. `--id <record-id>` pins the target when a
|
||||
// payment code owns more than one listing. As with fix-payload-version, --apply
|
||||
// refuses to run while the service is up, because store.ts holds the store in
|
||||
// memory as a single writer and would overwrite the edit.
|
||||
// =============================================================================
|
||||
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// canonicalPairing is imported, never reimplemented: this tool must accept
|
||||
// exactly what the submission gate accepts, and a second definition of the
|
||||
// canonical message would diverge silently. server/selftest.mjs enforces it.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const idFlag = argv.indexOf("--id");
|
||||
const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null;
|
||||
const FILES = argv.filter((a, i) =>
|
||||
!a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1));
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
if (!FILES.length) {
|
||||
console.error("Usage: node apply-signed-payload.ts [--apply] [--id <record-id>] <file>…\n" +
|
||||
"Each file contains one BEGIN BITCOIN SIGNED MESSAGE block.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
|
||||
catch (e: any) { active = (e.stdout || "").trim(); }
|
||||
if (active === "active") {
|
||||
console.error("REFUSING: dojobay-server.service is running.\n" +
|
||||
"The store is held in memory by the server and would overwrite this edit.\n" +
|
||||
" sudo systemctl stop dojobay-server.service\n" +
|
||||
" node apply-signed-payload.ts --apply <files…>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = JSON.parse(await readFile(FILE, "utf8"));
|
||||
const records: StoreRecord[] = Object.values(doc.submissions || {});
|
||||
|
||||
interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null }
|
||||
const planned: Planned[] = [];
|
||||
const refused: [string, string][] = [];
|
||||
|
||||
for (const file of FILES) {
|
||||
let signed: string;
|
||||
try { signed = await readFile(file, "utf8"); }
|
||||
catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; }
|
||||
|
||||
// Copying a block through chat, a form or a mail client routinely eats the
|
||||
// blank line before the BIP47 line, which the signature covers. Repair it if
|
||||
// a reconstruction verifies cryptographically; nothing is taken on trust.
|
||||
let note: string | null = null;
|
||||
const repaired = repairSignedBlock(signed);
|
||||
if (repaired) { signed = repaired.block; note = repaired.note; }
|
||||
|
||||
const parsed = parseSignedBlock(signed);
|
||||
if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; }
|
||||
if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; }
|
||||
|
||||
// The payload published is the one inside the signed block, never a
|
||||
// hand-copied version of it.
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(parsed.pairingText); }
|
||||
catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; }
|
||||
if (!payload?.pairing?.url || !payload?.pairing?.type) {
|
||||
refused.push([file, "the signed payload has no pairing.url/type"]); continue;
|
||||
}
|
||||
|
||||
const addrs = notificationAddresses(parsed.paymentCode);
|
||||
const v = verifySignedPayload({
|
||||
signedText: signed,
|
||||
expectedMessage: canonicalPairing(payload),
|
||||
expectedAddress: addrs,
|
||||
});
|
||||
if (!v.ok) { refused.push([file, v.error]); continue; }
|
||||
|
||||
const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!));
|
||||
const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined);
|
||||
if (!owned.length) {
|
||||
refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue;
|
||||
}
|
||||
if (!target) {
|
||||
refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue;
|
||||
}
|
||||
|
||||
planned.push({
|
||||
file, rec: target, payload, signed: signed.trim(), note,
|
||||
before: target.payload?.pairing?.url || "(none)",
|
||||
after: payload.pairing.url,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Blocks read: ${FILES.length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Will update (${planned.length}):`);
|
||||
for (const p of planned) {
|
||||
console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`);
|
||||
console.log(` url ${p.before}`);
|
||||
console.log(` -> ${p.after}`);
|
||||
const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version;
|
||||
if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`);
|
||||
if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)");
|
||||
if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
if (refused.length) {
|
||||
console.log(`Refused (${refused.length}):`);
|
||||
for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to make these changes.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = `${FILE}.bak-${stamp}`;
|
||||
await copyFile(FILE, backup);
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const p of planned) {
|
||||
const rec = doc.submissions[p.rec.id];
|
||||
rec.payload = p.payload; // exactly what was signed
|
||||
rec.signed = p.signed;
|
||||
rec.updated_at = nowIso;
|
||||
}
|
||||
// A temporary name no other writer can take; see build-public.ts. This tool
|
||||
// refuses to run while the service holds the store, so a collision needs two
|
||||
// maintenance tools at once, which is exactly the case nobody plans for.
|
||||
const tmp = `${FILE}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
|
||||
console.log(`Backup written: ${backup}`);
|
||||
console.log(`Applied ${planned.length} update(s).`);
|
||||
console.log("Start the service again, then run audit-signed.mjs; each updated record\n" +
|
||||
"should now read VERIFIED. The published dojos.json follows on the next\n" +
|
||||
"updater cycle, or immediately if you run build-public.mjs.");
|
||||
process.exit(refused.length ? 1 : 0);
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — audit stored signed pairing blocks.
|
||||
//
|
||||
// READ-ONLY. Walks every record in the submission store and re-checks its
|
||||
// stored `signed` block with exactly the gate the submit endpoint uses
|
||||
// (verifySignedPayload over canonicalPairing(payload), against the notification
|
||||
// address of the record's own payment code). Nothing is written, no network is
|
||||
// touched, and the store is only ever read.
|
||||
//
|
||||
// Why this exists: records approved before the signed-message parser was fixed
|
||||
// were checked by a parser that excised the BIP47 tail before verifying, so the
|
||||
// verdict they received then is not the verdict they would receive now. This
|
||||
// tells you whether anything was left behind.
|
||||
//
|
||||
// Run on the box as the deploy user:
|
||||
// cd /var/www/dojobay/server && node audit-signed.mjs
|
||||
// SERVER_DATA_DIR defaults to ./data, the same path the server uses; set it
|
||||
// only if your store lives elsewhere.
|
||||
//
|
||||
// Buckets:
|
||||
// VERIFIED the stored signature is valid for one of the record's codes
|
||||
// FAILED a signature is present but verifies for none of them
|
||||
// UNSIGNED no signature stored (pre-gate migration, or a code-less record)
|
||||
// ERROR the record could not be evaluated at all
|
||||
// Exits non-zero if anything is FAILED, ERROR or UNSIGNED, so it can back a
|
||||
// cron check. UNSIGNED counted as a failure since the signature became a
|
||||
// structural requirement: the store refuses to write such a record and the
|
||||
// rebuild withholds it, so one showing up here is not awaiting a decision.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
import { verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
|
||||
const networkOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Exported so the test suite can assert this reproduces the gate's verdict.
|
||||
// This MUST mirror server/index.mjs's signature gate exactly: same canonical
|
||||
// message, and the same set of acceptable signing addresses. An earlier version
|
||||
// derived the notification address for the record's own network, which meant
|
||||
// every testnet listing was reported as failing even though the gate accepted
|
||||
// it, because a PayNym signs from its mainnet address whatever the node is.
|
||||
export function auditRecord(rec) {
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
if (!rec.signed) {
|
||||
return { bucket: "UNSIGNED", detail: codes.length ? "record has a payment code but no signed block" : "no signed block and no payment code" };
|
||||
}
|
||||
const net = networkOf(rec);
|
||||
const expectedMessage = canonicalPairing(rec.payload);
|
||||
const tried = [];
|
||||
// A PayNym may have signed with either BIP47 variant, so every code on the
|
||||
// record is a legitimate candidate; the first that verifies wins.
|
||||
for (const code of codes) {
|
||||
const addrs = notificationAddresses(code);
|
||||
if (!addrs.length) { tried.push(`${code.slice(0, 12)}…: undecodable code`); continue; }
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, expectedAddress: addrs, network: net });
|
||||
if (r.ok) return { bucket: "VERIFIED", detail: `${code.slice(0, 12)}… → ${addrs[0]}` };
|
||||
tried.push(`${code.slice(0, 12)}… (${addrs.join(" / ")}): ${r.error}`);
|
||||
}
|
||||
if (!codes.length) {
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, network: net });
|
||||
return r.ok
|
||||
? { bucket: "FAILED", detail: "signature is internally valid but the record carries no payment code to bind it to" }
|
||||
: { bucket: "FAILED", detail: r.error };
|
||||
}
|
||||
return { bucket: "FAILED", detail: tried.join("\n ") };
|
||||
}
|
||||
|
||||
// ---- CLI ---------------------------------------------------------------
|
||||
// Only runs when executed directly, so tests can import auditRecord.
|
||||
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (!isMain) { /* imported for testing */ } else {
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const buckets = { VERIFIED: [], FAILED: [], UNSIGNED: [], ERROR: [] };
|
||||
for (const rec of recs) {
|
||||
let res;
|
||||
try { res = auditRecord(rec); } catch (e) { res = { bucket: "ERROR", detail: e.message }; }
|
||||
buckets[res.bucket].push({ rec, detail: res.detail });
|
||||
}
|
||||
|
||||
console.log(`Audited ${recs.length} record(s) in the store.\n`);
|
||||
for (const b of ["FAILED", "ERROR", "UNSIGNED", "VERIFIED"]) {
|
||||
if (!buckets[b].length) continue;
|
||||
console.log(`${b}: ${buckets[b].length}`);
|
||||
for (const { rec, detail } of buckets[b]) {
|
||||
// Show the name as well as the id. Ids are immutable (reliability history
|
||||
// keys on them), so a record created before operator naming keeps its
|
||||
// payment-code-derived id even after its operator sets a name, and the id
|
||||
// alone is then unrecognisable.
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(` [${b}] ${label} (${rec.status})${detail ? "\n " + detail : ""}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// An UNSIGNED record is now a failure, not a decision. Until the signature rule
|
||||
// existed there was a legitimate answer to "this record predates the gate" and
|
||||
// the audit deliberately left the judgement to a maintainer. The store now
|
||||
// refuses to write such a record and the rebuild withholds it, so one appearing
|
||||
// here means something got in around those rules or predates them, and either
|
||||
// way it is not being published and needs dealing with.
|
||||
const bad = buckets.FAILED.length + buckets.ERROR.length + buckets.UNSIGNED.length;
|
||||
console.log(
|
||||
`Summary: ${buckets.VERIFIED.length} verified, ${buckets.FAILED.length} failed, ` +
|
||||
`${buckets.UNSIGNED.length} unsigned, ${buckets.ERROR.length} error.` +
|
||||
(buckets.UNSIGNED.length ? "\nUNSIGNED records are withheld from the public list. Ask the operator to sign their\npairing payload and resubmit, or remove the listing with server/remove-listing.ts." : "") +
|
||||
(bad ? `\nNON-ZERO EXIT: ${bad} record(s) need attention.` : "\nEvery record carries a signature and every signature verifies under the current gate."));
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Launcher for the public-list rebuild, which lives in build-public.ts.
|
||||
//
|
||||
// Kept as plain JavaScript, and kept under this name, for the same reasons as
|
||||
// index.mjs:
|
||||
//
|
||||
// 1. It parses on any Node, so an operator on an older runtime gets the
|
||||
// message below rather than a syntax error from a file their Node cannot
|
||||
// execute. The check must precede the import, hence the dynamic import.
|
||||
// 2. A lot of things outside this file invoke it by name: the deploy workflow,
|
||||
// `npm run build-public`, scripts/install.mjs, and — importantly —
|
||||
// scripts/apply-update.mjs, which spawns it during a self-update. That
|
||||
// helper is the OLD copy still running while new files are swapped in, so
|
||||
// an instance updating ACROSS a rename would spawn a file that no longer
|
||||
// exists and its rebuild would fail.
|
||||
//
|
||||
// New in-process callers should import ./build-public.ts directly.
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isNaN(major) || major < 24) {
|
||||
console.error(
|
||||
`The Dojo Bay rebuild needs Node 24 or newer (found ${process.versions.node}).\n` +
|
||||
"It runs TypeScript directly, which relies on type stripping added in Node 24.\n" +
|
||||
"Upgrade Node, then re-run the rebuild.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import("./build-public.ts");
|
||||
export const rebuild = mod.rebuild;
|
||||
export const displayPaymentCode = mod.displayPaymentCode;
|
||||
export const effectiveVersion = mod.effectiveVersion;
|
||||
export const effectiveIndexer = mod.effectiveIndexer;
|
||||
export const retireUnlisted = mod.retireUnlisted;
|
||||
|
||||
// Run the rebuild when invoked directly (the .ts module's own check does not
|
||||
// fire in that case, because argv[1] is this launcher).
|
||||
import { pathToFileURL } from "node:url";
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await mod.rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge the curated seed list with APPROVED self-service submissions into the
|
||||
// public data/dojos.json that the front-end and the 10-minute updater consume.
|
||||
// The seed list (data/seed.json) stays under maintainer control; only approved
|
||||
// submissions are added. A newly-approved node inherits the status, block
|
||||
// height and reliability history the updater already recorded for it while it
|
||||
// was pending (see scripts/update.mjs and server/data/pending-probe.json), so
|
||||
// it appears active with its uptime intact the moment it is published.
|
||||
//
|
||||
// Exposes rebuild() for in-process use by the admin API; runs it when invoked
|
||||
// directly from the CLI.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { store, hasSignedBlock } from "./store.ts";
|
||||
import { urlOnDomain } from "./domains.ts";
|
||||
import type { PublicNode, PairingPayload, StoreRecord } from "../types.js";
|
||||
|
||||
/** The generated data/dojos.json. */
|
||||
interface PublicDoc {
|
||||
generated_at?: string;
|
||||
interval_minutes?: number;
|
||||
nodes: PublicNode[];
|
||||
}
|
||||
/** A history file: per-node check lists or daily rollups, keyed by record id. */
|
||||
type HistoryMap = Record<string, any>;
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function readJSON<T>(p: string, fallback: T): Promise<T> {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); }
|
||||
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e; }
|
||||
}
|
||||
// A temporary name no other writer can take. `<file>.tmp` is not atomic
|
||||
// between processes: two writers produce the same path, the first rename
|
||||
// consumes it, and the second fails with ENOENT on a file it had just written.
|
||||
// See scripts/update.mjs for the install that did exactly that.
|
||||
let tmpSeq = 0;
|
||||
async function writeAtomic(p, obj) {
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
}
|
||||
|
||||
// The payment code shown on a card. A PayNym commonly has two BIP47 variants
|
||||
// and records store every variant; the canonical one people share (and the one
|
||||
// shown on paynym.rs profiles) is the NON-segwit code, so prefer that when the
|
||||
// paynym-codes mapping can identify it, falling back to the record's first.
|
||||
// Exported for the self-test.
|
||||
/** Only the two fields it actually reads, so callers need not build a whole
|
||||
* record to ask which variant to display. */
|
||||
type CodeBearing = { paymentCodes?: string[] | null; paynym?: string | null };
|
||||
|
||||
export function displayPaymentCode(sub: CodeBearing, mapping: any): string | null {
|
||||
const codes = Array.isArray(sub.paymentCodes) ? sub.paymentCodes : [];
|
||||
if (!codes.length) return null;
|
||||
const entry = sub.paynym && mapping && mapping[sub.paynym];
|
||||
const legacy = entry && (entry.codes || []).find((c) => !c.segwit && codes.includes(c.code));
|
||||
return (legacy && legacy.code) || codes[0];
|
||||
}
|
||||
|
||||
// The version shown on a card is derived entirely from the node's API, never
|
||||
// set by an operator. In priority order:
|
||||
// 1. the version the updater last read live from the node's X-Dojo-Version
|
||||
// response header (detected_version, carried in dojos.json),
|
||||
// 2. the version in the pairing payload, used only as a bootstrap fallback
|
||||
// until the first probe reads a live header (and for older nodes that do
|
||||
// not emit the header). It is itself an API value, captured from the
|
||||
// Dojo's pairing output at submission time.
|
||||
// There is deliberately no operator override: the version always reflects what
|
||||
// the node reports. To show nothing until a live header is read, drop the
|
||||
// pairing fallback.
|
||||
export function effectiveVersion(detected: string | null | undefined, pairing: string | null | undefined): string | null {
|
||||
return detected || pairing || null;
|
||||
}
|
||||
|
||||
// The Electrum endpoint shown on a card. Only what the node reported about
|
||||
// itself: the updater reads it from the Dojo's /support/services each cycle,
|
||||
// over the API onion the operator's signature fixes.
|
||||
//
|
||||
// A URL declared in a submitted payload is NOT a fallback and must not become
|
||||
// one: nothing signs it, and a node that is healthy but exposes no indexer
|
||||
// never acquires a detected value, so a declared URL would be published for
|
||||
// good. docs/decisions.md, entry 00d07ae, has the reasoning.
|
||||
//
|
||||
// Null means the card shows N/A, which is a real answer (no exposed indexer)
|
||||
// rather than an omission, and is now reachable for every node.
|
||||
export function effectiveIndexer(detected: string | null | undefined): string | null {
|
||||
return detected || null;
|
||||
}
|
||||
|
||||
// Every key the published dojos.json may contain for a node. Exported so the
|
||||
// suite can assert on it rather than restating it, and so that adding a field
|
||||
// to toPublicNode without adding it here fails the gate: publishing a new field
|
||||
// should be a decision somebody makes, not a consequence of editing a record
|
||||
// shape somewhere else.
|
||||
export const PUBLIC_NODE_KEYS = Object.freeze([
|
||||
"id", "network", "name", "status", "paynym", "paymentCode",
|
||||
"jurisdiction", "country", "hardware", "version", "detected_version",
|
||||
"detected_indexer", "operator_domain", "operator_domain_proof",
|
||||
"block_height", "indexer_url", "checked_at", "payload", "signed",
|
||||
]);
|
||||
|
||||
// The allowlist itself, and the only producer of a published node.
|
||||
//
|
||||
// It names every field rather than deleting the ones it does not want, which is
|
||||
// the distinction that matters: a redaction list is wrong by default and has to
|
||||
// be updated whenever the store gains a field, whereas this is right by default
|
||||
// and has to be updated whenever the PUBLIC shape should change. The store
|
||||
// holds things that must never be published (moderation status, the owning
|
||||
// payment codes, submission timestamps, the probe result recorded at
|
||||
// submission, import provenance) and it will hold more in future.
|
||||
//
|
||||
// One field is copied wholesale rather than picked apart: `payload`. That is
|
||||
// deliberate, since the pairing payload including its API key is the entire
|
||||
// point of a listing and a visitor needs it byte for byte to pair. It does mean
|
||||
// the allowlist has a nested edge: anything added inside payload is published.
|
||||
// The store gate is what keeps that honest, since payload is what the operator
|
||||
// signed and the signature covers its exact contents.
|
||||
function toPublicNode(sub: StoreRecord, paymentCode: string | null): PublicNode {
|
||||
return {
|
||||
id: sub.id,
|
||||
network: sub.network,
|
||||
name: sub.name || sub.paynym || sub.id,
|
||||
status: "inactive",
|
||||
paynym: sub.paynym || null,
|
||||
paymentCode: paymentCode || null,
|
||||
jurisdiction: sub.jurisdiction || null,
|
||||
country: sub.country || null,
|
||||
hardware: sub.hardware || null,
|
||||
// Initial version is the pairing-payload fallback; rebuild() recomputes it
|
||||
// via effectiveVersion once the live-detected value is known.
|
||||
version: sub.payload?.pairing?.version || null,
|
||||
detected_version: null,
|
||||
detected_indexer: null,
|
||||
operator_domain: null,
|
||||
operator_domain_proof: null,
|
||||
block_height: null,
|
||||
indexer_url: null,
|
||||
checked_at: null,
|
||||
payload: sub.payload,
|
||||
signed: sub.signed || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Grace-period retirement for history entries. Deleting history the instant an
|
||||
// id leaves the node list turned a transient list mistake into permanent data
|
||||
// loss (the seed-migration deploy wiped every migrated node's history seconds
|
||||
// after rsync, via the post-deploy rebuild, before the migration could run on
|
||||
// the box). Instead: an unlisted id is STAMPED `retired` and kept; it is only
|
||||
// deleted after HISTORY_GRACE_DAYS (default 14); if the id is listed again
|
||||
// within the window, the stamp is cleared and its history resumes untouched.
|
||||
// Exported because scripts/update.mjs rewrites the same two files every cycle
|
||||
// and must apply identical rules.
|
||||
export function retireUnlisted(nodesMap: HistoryMap, isListed: (id: string) => boolean,
|
||||
nowIso: string, graceDays: number = Number(process.env.HISTORY_GRACE_DAYS || 14)): boolean {
|
||||
let touched = false;
|
||||
const cutoffMs = Date.parse(nowIso) - graceDays * 86400000;
|
||||
for (const id of Object.keys(nodesMap)) {
|
||||
const entry = nodesMap[id];
|
||||
if (isListed(id)) {
|
||||
if (entry.retired) { delete entry.retired; touched = true; }
|
||||
} else if (!entry.retired) {
|
||||
entry.retired = nowIso; touched = true;
|
||||
} else if (Date.parse(entry.retired) < cutoffMs) {
|
||||
delete nodesMap[id]; touched = true;
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
export async function rebuild(): Promise<{ nodes: number; approved: number; msg: string }> {
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
const SERVER_DATA = process.env.SERVER_DATA_DIR || path.join(ROOT, "server", "data");
|
||||
const SEED = path.join(DATA_DIR, "seed.json");
|
||||
const OUT = path.join(DATA_DIR, "dojos.json");
|
||||
const HIST = path.join(DATA_DIR, "history.json");
|
||||
const DAILY = path.join(DATA_DIR, "history-daily.json");
|
||||
const PENDING_PROBE = path.join(SERVER_DATA, "pending-probe.json");
|
||||
|
||||
const seed = await readJSON(SEED, { nodes: [] });
|
||||
// Optional: identifies each PayNym's non-segwit code variant for display.
|
||||
const codesDoc = await readJSON(path.join(DATA_DIR, "paynym-codes.json"), { mapping: {} });
|
||||
// The operator binding is REQUIRED: an instance must prove who runs it.
|
||||
// Warn (unmissably) rather than fail, so a malformed signature nags the
|
||||
// operator without taking the directory down for its visitors. The crypto
|
||||
// import is lazy so the dependency-free scripts/ chain can still import
|
||||
// this module on a box where server/node_modules is not installed yet.
|
||||
try {
|
||||
const opDoc = await readJSON(path.join(DATA_DIR, "operator.json"), null);
|
||||
if (!opDoc) {
|
||||
console.error("[rebuild] REQUIRED: data/operator.json is missing. Sign your onion URL with your wallet and install the binding (the installer does this); see README.");
|
||||
} else {
|
||||
try {
|
||||
const { verifyOperatorDoc } = await import("./crypto.ts");
|
||||
const v = verifyOperatorDoc(opDoc);
|
||||
if (!v.ok) console.error(`[rebuild] REQUIRED: data/operator.json does not verify: ${v.error}`);
|
||||
} catch { console.error("[rebuild] note: cannot verify operator.json (server dependencies not installed)."); }
|
||||
}
|
||||
} catch (e) { console.error(`[rebuild] operator.json check skipped: ${e.message}`); }
|
||||
|
||||
// Anchor-model checks (warnings, never fatal: a fresh instance mid-setup or
|
||||
// mid-transition should build, just noisily). The seed should hold exactly
|
||||
// one node -- the instance operator's own, carrying their payment code --
|
||||
// and every listed node should carry a BIP47 code; code-less records are
|
||||
// grandfathered exceptions managed from /admin.
|
||||
if ((seed.nodes || []).length !== 1) {
|
||||
console.error(`[rebuild] note: seed carries ${(seed.nodes || []).length} node(s); the anchor model expects exactly one (the instance operator's own node).`);
|
||||
} else if (!seed.nodes[0].paymentCode) {
|
||||
console.error(`[rebuild] REFUSING to publish the anchor seed node ${seed.nodes[0].id}: it has no BIP47 payment code.`);
|
||||
}
|
||||
// A record with no payment code and no signed pairing block is not published.
|
||||
// The store refuses to write either, so this only fires for something that
|
||||
// predates those rules or was edited by hand — and in that case it is
|
||||
// withheld rather than shown, because a listing nobody can be held to, or
|
||||
// whose details nobody has attested to, is exactly what this directory must
|
||||
// not carry. Withheld, not deleted: the record stays for a maintainer to look
|
||||
// at. The two are reported separately because the remedies differ: a missing
|
||||
// code cannot be supplied by anyone but the operator, while a missing
|
||||
// signature usually means asking them to sign what they already gave us.
|
||||
const allApproved = (await store.listSubmissions()).filter((s) => s.status === "approved");
|
||||
const codeless = allApproved.filter((s) => !(s.paymentCodes || []).length);
|
||||
if (codeless.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${codeless.length} listing(s) with no BIP47 payment code: ${codeless.map((s) => s.id).join(", ")}. A listing must carry a payment code; remove it with server/remove-listing.ts, or give it one.`);
|
||||
}
|
||||
const unsigned = allApproved.filter((s) => (s.paymentCodes || []).length && !hasSignedBlock(s));
|
||||
if (unsigned.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${unsigned.length} listing(s) with no signed pairing block: ${unsigned.map((s) => s.id).join(", ")}. Ask the operator to sign their pairing payload and resubmit, or remove the listing with server/remove-listing.ts.`);
|
||||
}
|
||||
const approvedSubs = allApproved.filter((s) => (s.paymentCodes || []).length && hasSignedBlock(s));
|
||||
const approved = approvedSubs.map((s) => toPublicNode(s, displayPaymentCode(s, codesDoc.mapping)));
|
||||
const approvedIds = new Set(approved.map((n) => n.id));
|
||||
|
||||
const byId = new Map();
|
||||
// The seed anchor is held to the same rules as any other listing.
|
||||
const seedNodes = (seed.nodes || []).filter((n) => {
|
||||
if (!n || !n.paymentCode) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no BIP47 payment code.`);
|
||||
return false;
|
||||
}
|
||||
if (!hasSignedBlock(n)) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no signed pairing block.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Seed nodes go through the SAME allowlist as store records. They used to be
|
||||
// published as they sit in data/seed.json, which meant the public file had two
|
||||
// producers and only one of them filtered anything. Nothing has ever leaked
|
||||
// that way, because seed.json is written by the installer and its fields
|
||||
// happen to be a subset of what toPublicNode emits, but "happens to be a
|
||||
// subset" is not a property anybody was maintaining: seed.json is
|
||||
// instance-owned and documented as hand-editable, so a field added there went
|
||||
// straight to the published file unread. One producer, one allowlist.
|
||||
//
|
||||
// The cast is safe because toPublicNode reads only fields a seed node has;
|
||||
// the owning code is passed as an argument rather than read from the record,
|
||||
// which is why a seed node's singular paymentCode needs no reshaping.
|
||||
for (const n of seedNodes) byId.set(n.id, toPublicNode(n as unknown as StoreRecord, n.paymentCode || null));
|
||||
for (const n of approved) byId.set(n.id, n);
|
||||
const nodes = [...byId.values()];
|
||||
|
||||
// Per-id pairing version, the bootstrap fallback used until a live version is
|
||||
// detected. The card version is never operator-set (see effectiveVersion).
|
||||
const pairingById = new Map();
|
||||
for (const n of seedNodes) pairingById.set(n.id, n.payload?.pairing?.version || null);
|
||||
for (const s of approvedSubs) pairingById.set(s.id, s.payload?.pairing?.version || null);
|
||||
|
||||
// Owner payment codes per node, for the verified-domain lookup below. The seed
|
||||
// anchor carries a single paymentCode; store records carry paymentCodes[].
|
||||
const ownerCodesById = new Map();
|
||||
for (const n of seedNodes) ownerCodesById.set(n.id, [n.paymentCode]);
|
||||
for (const sub of approvedSubs) ownerCodesById.set(sub.id, sub.paymentCodes || []);
|
||||
|
||||
// Carry over the live status the updater last wrote, so a rebuild does not
|
||||
// blank a node for a probe cycle.
|
||||
const prior = await readJSON(OUT, { nodes: [] });
|
||||
const priorById = new Map((prior.nodes || []).map((n) => [n.id, n]));
|
||||
// Pending-probe results (updater-owned): seed a just-approved node's status
|
||||
// and height from what was observed while it was pending.
|
||||
const pending = await readJSON(PENDING_PROBE, { nodes: {} });
|
||||
// Verified operator domains: published per node so the card can show the badge
|
||||
// without another lookup, and used to filter the card-title link. A link that
|
||||
// is not on the operator's verified domain is withheld rather than deleted, so
|
||||
// an operator who verifies later gets their link back untouched.
|
||||
const domainByCode = await store.verifiedDomainMap();
|
||||
// The proof is published alongside the badge so a reader can check it with
|
||||
// their own tools instead of taking our tick on trust: the TXT record proves
|
||||
// the domain names the payment code, and the signed statement proves the code
|
||||
// names the domain. Everything here is already public (the payment code is on
|
||||
// the card, the domain is the claim), so publishing it discloses nothing new.
|
||||
const claimByCode = new Map<string, { signed: string; verified_at: string | null }>();
|
||||
for (const c of await store.listDomains()) {
|
||||
if (c?.verified && c.domain) claimByCode.set(c.paymentCode, { signed: c.signed, verified_at: c.verified_at ?? null });
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const codes = ownerCodesById.get(n.id) || [];
|
||||
const code = codes.find((c) => domainByCode.get(c)) || null;
|
||||
const domain = code ? domainByCode.get(code) || null : null;
|
||||
n.operator_domain = domain;
|
||||
const claim = code ? claimByCode.get(code) : null;
|
||||
n.operator_domain_proof = domain && claim ? {
|
||||
domain,
|
||||
paymentCode: code,
|
||||
txt_name: `_dojobay.${domain}`,
|
||||
txt_value: `dojobay-domain-v1 pm=${code}`,
|
||||
signed: claim.signed,
|
||||
verified_at: claim.verified_at,
|
||||
} : null;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const p = priorById.get(n.id);
|
||||
const pr = (!p && approvedIds.has(n.id)) ? pending.nodes?.[n.id] : null;
|
||||
if (p) {
|
||||
n.status = p.status ?? n.status;
|
||||
n.checked_at = p.checked_at ?? n.checked_at;
|
||||
if (p.block_height != null) n.block_height = p.block_height;
|
||||
} else if (pr) {
|
||||
n.status = pr.status ?? n.status;
|
||||
n.checked_at = pr.checked_at ?? n.checked_at;
|
||||
if (pr.block_height != null) n.block_height = pr.block_height;
|
||||
}
|
||||
// Carry the live-detected version (prior snapshot, then a just-approved
|
||||
// node's pending probe) and fold it into the effective card version. The
|
||||
// updater writes detected_version each cycle; a rebuild must preserve it,
|
||||
// exactly as it preserves status and block height.
|
||||
const detected = (p && p.detected_version) || (pr && pr.detected_version) || null;
|
||||
n.detected_version = detected;
|
||||
n.version = effectiveVersion(detected, pairingById.get(n.id));
|
||||
// Same treatment for the Electrum endpoint: carry what the updater read and
|
||||
// publish it as indexer_url, which the card renders (N/A when null).
|
||||
const detectedIdx = (p && p.detected_indexer) || (pr && pr.detected_indexer) || null;
|
||||
n.detected_indexer = detectedIdx;
|
||||
n.indexer_url = effectiveIndexer(detectedIdx);
|
||||
}
|
||||
|
||||
await writeAtomic(OUT, {
|
||||
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||
interval_minutes: 10,
|
||||
nodes,
|
||||
});
|
||||
|
||||
// Reliability history: ensure a bucket per node, seed a newly-approved node's
|
||||
// history from its pending history, and retire (grace period) unlisted ids.
|
||||
const hist = await readJSON(HIST, { interval_minutes: 10, window_checks: 144, nodes: {} });
|
||||
let touched = false;
|
||||
for (const n of nodes) {
|
||||
if (!hist.nodes[n.id]) {
|
||||
const seedChecks = (approvedIds.has(n.id) && pending.nodes?.[n.id]?.checks) || [];
|
||||
hist.nodes[n.id] = { checks: seedChecks.slice() };
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
touched = retireUnlisted(hist.nodes, (id) => byId.has(id), nowIso) || touched;
|
||||
if (touched) { (hist as any).generated_at = (hist as any).generated_at || null; await writeAtomic(HIST, hist); }
|
||||
|
||||
// 90-day daily rollup membership.
|
||||
const dailyDoc = await readJSON(DAILY, { retention_days: 90, nodes: {} });
|
||||
let dailyTouched = false;
|
||||
for (const n of nodes) if (!dailyDoc.nodes[n.id]) {
|
||||
dailyDoc.nodes[n.id] = { days: (approvedIds.has(n.id) && pending.nodes?.[n.id]?.days) ? pending.nodes[n.id].days.slice() : [] };
|
||||
dailyTouched = true;
|
||||
}
|
||||
dailyTouched = retireUnlisted(dailyDoc.nodes, (id) => byId.has(id), nowIso) || dailyTouched;
|
||||
if (dailyTouched) await writeAtomic(DAILY, dailyDoc);
|
||||
|
||||
const msg = `public list rebuilt: ${nodes.length} nodes (${approved.length} approved submissions).`;
|
||||
return { nodes: nodes.length, approved: approved.length, msg };
|
||||
}
|
||||
|
||||
// Run when invoked directly.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — resource diagnostic.
|
||||
//
|
||||
// READ-ONLY. Measures what this instance actually uses, rather than guessing,
|
||||
// so an operator can size a VPS from evidence and this project can document a
|
||||
// requirement it has tested.
|
||||
//
|
||||
// What it looks at, and why each matters for THIS workload:
|
||||
//
|
||||
// memory the backend is a small long-running Node process; the updater is
|
||||
// a second one every ten minutes; tor and nginx sit alongside.
|
||||
// Peak matters more than current, because `npm ci` during a deploy
|
||||
// and the unzip during a self-update are the two spikes.
|
||||
// disk node_modules, the published data, and — the one that grows
|
||||
// without limit — data/backups, a full copy of the code kept by
|
||||
// every self-update.
|
||||
// cpu idle almost always, with a burst each probe cycle: one Tor
|
||||
// circuit per listed node, plus secp256k1 verification.
|
||||
// strain swap in use, OOM kills and load average are the evidence that a
|
||||
// box is actually too small, as opposed to merely modest.
|
||||
//
|
||||
// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and
|
||||
// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and
|
||||
// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and
|
||||
// which is a real if narrow bug: a value beginning with a hyphen is read by
|
||||
// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures
|
||||
// something other than what was asked for. Both are now answered by Node
|
||||
// itself, statfs() and a walk, which removes the class rather than escaping
|
||||
// around it. The two subprocesses that remain (systemctl, journalctl) exist
|
||||
// because nothing in Node can answer what they answer, and both take arguments
|
||||
// written here. Keep it that way: see sh() below.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server && node check-resources.ts
|
||||
// =============================================================================
|
||||
import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, "..");
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data");
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const mb = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB";
|
||||
return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB";
|
||||
};
|
||||
const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB";
|
||||
const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } };
|
||||
|
||||
// Every call site passes a command and an argument list written in this file,
|
||||
// never a path, a name or anything else derived from the environment. The one
|
||||
// exception is UNITS below, which the suite checks directly. A future edit that
|
||||
// interpolates a variable in here fails the gate rather than shipping.
|
||||
const sh = async (cmd: string, args: string[]) => {
|
||||
try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; }
|
||||
};
|
||||
|
||||
// The only values this file passes to a subprocess that are not written inline
|
||||
// at the call site. They are exported so the suite can assert on the array
|
||||
// itself rather than reading this source and guessing: an assertion about what
|
||||
// a program does is worth more than one about how it is spelled.
|
||||
export const UNITS = [
|
||||
"dojobay-server.service",
|
||||
"dojobay-update.service",
|
||||
"tor.service",
|
||||
"nginx.service",
|
||||
];
|
||||
|
||||
// Replaces `df`. statfs reports the filesystem holding the path, and the
|
||||
// arithmetic matches what df prints: used counts the blocks the filesystem
|
||||
// considers occupied, while available excludes the root reserve, so used plus
|
||||
// available is legitimately less than the total.
|
||||
export const diskUsage = async (p: string) => {
|
||||
try {
|
||||
const fs = await statfs(p);
|
||||
const block = Number(fs.bsize);
|
||||
return {
|
||||
size: Number(fs.blocks) * block,
|
||||
used: (Number(fs.blocks) - Number(fs.bfree)) * block,
|
||||
avail: Number(fs.bavail) * block,
|
||||
};
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// Replaces `du -sb`: apparent size of a tree, symlinks counted but never
|
||||
// followed, unreadable entries skipped rather than fatal, and directory inodes
|
||||
// excluded, which is what `du -sb` does and is why this agrees with it to the
|
||||
// byte on a real node_modules. Counting the directories instead would add 4 KB
|
||||
// per directory of filesystem bookkeeping to a figure meant to describe
|
||||
// content. One difference remains: du counts a hard-linked file once, this
|
||||
// counts it once per link, which node_modules does not contain and which would
|
||||
// overstate rather than hide. It also walks in JavaScript, so a populated
|
||||
// node_modules takes a second or so rather than being instant, which is nothing
|
||||
// for a diagnostic run by hand a few times a year.
|
||||
export const dirSize = async (p: string): Promise<number | null> => {
|
||||
const root = await lstat(p).catch(() => null);
|
||||
if (!root) return null;
|
||||
if (!root.isDirectory()) return root.size;
|
||||
let total = 0;
|
||||
const walk = async (dir: string) => {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { await walk(full); continue; }
|
||||
const s = await lstat(full).catch(() => null);
|
||||
if (s) total += s.size;
|
||||
}
|
||||
};
|
||||
await walk(p);
|
||||
return total;
|
||||
};
|
||||
|
||||
const report = async () => {
|
||||
console.log("The Dojo Bay — what this instance actually uses\n");
|
||||
|
||||
// ---- the machine ----------------------------------------------------------
|
||||
const meminfo = (await read("/proc/meminfo")) || "";
|
||||
const kb = (key: string) => {
|
||||
const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m"));
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable");
|
||||
const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree");
|
||||
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null;
|
||||
const cpus = os.cpus();
|
||||
|
||||
console.log("MACHINE");
|
||||
console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`);
|
||||
console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`);
|
||||
console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`);
|
||||
const la = os.loadavg();
|
||||
console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`);
|
||||
|
||||
const disk = await diskUsage(WEB_ROOT);
|
||||
const diskFree = disk ? disk.avail : null;
|
||||
if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`);
|
||||
|
||||
// ---- what our services use ------------------------------------------------
|
||||
console.log("\nSERVICES (current / peak since boot)");
|
||||
let ourPeak = 0;
|
||||
for (const unit of UNITS) {
|
||||
const base = `/sys/fs/cgroup/system.slice/${unit}`;
|
||||
const cur = Number((await read(`${base}/memory.current`)) || 0);
|
||||
const peak = Number((await read(`${base}/memory.peak`)) || 0);
|
||||
const active = await sh("systemctl", ["is-active", unit]);
|
||||
if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; }
|
||||
if (unit.startsWith("dojobay")) ourPeak += peak || cur;
|
||||
console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`);
|
||||
}
|
||||
|
||||
// ---- disk, broken down ----------------------------------------------------
|
||||
console.log("\nDISK USED BY THIS INSTALLATION");
|
||||
const parts: [string, string][] = [
|
||||
["everything", WEB_ROOT],
|
||||
[" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")],
|
||||
[" data (published)", PUBLIC_DIR],
|
||||
[" data/avatars", path.join(PUBLIC_DIR, "avatars")],
|
||||
[" data/backups", path.join(PUBLIC_DIR, "backups")],
|
||||
[" data/updates", path.join(PUBLIC_DIR, "updates")],
|
||||
];
|
||||
let backupsBytes = 0, backupCount = 0;
|
||||
for (const [label, p] of parts) {
|
||||
const bytes = await dirSize(p);
|
||||
if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; }
|
||||
if (label.includes("backups")) {
|
||||
backupsBytes = bytes;
|
||||
try { backupCount = (await readdir(p)).length; } catch { /* none */ }
|
||||
}
|
||||
console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`);
|
||||
}
|
||||
|
||||
// ---- the workload ---------------------------------------------------------
|
||||
console.log("\nWORKLOAD");
|
||||
let nodeCount = 0, intervalMin = 10;
|
||||
try {
|
||||
const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}");
|
||||
nodeCount = (dojos.nodes || []).length;
|
||||
intervalMin = Number(dojos.interval_minutes) || 10;
|
||||
} catch { /* not built yet */ }
|
||||
const concurrency = Number(process.env.CONCURRENCY || 4);
|
||||
console.log(` listed nodes ${nodeCount}`);
|
||||
console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`);
|
||||
for (const f of ["dojos.json", "history.json", "history-daily.json"]) {
|
||||
const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null);
|
||||
if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`);
|
||||
}
|
||||
|
||||
// ---- evidence of strain ---------------------------------------------------
|
||||
// journalctl does its own matching, so there is no pipeline and no shell: the
|
||||
// filter is an argument, the output is one line per matching entry, and a
|
||||
// journalctl that cannot answer leaves this null exactly as an absent one did.
|
||||
console.log("\nSIGNS OF STRAIN");
|
||||
const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false",
|
||||
"--grep=out of memory", "--output=cat"]);
|
||||
const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0;
|
||||
const findings: string[] = [];
|
||||
if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`);
|
||||
if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`);
|
||||
if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now");
|
||||
if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`);
|
||||
if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`);
|
||||
if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`);
|
||||
if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything.");
|
||||
else for (const f of findings) console.log(` · ${f}`);
|
||||
|
||||
// ---- what to tell other operators -----------------------------------------
|
||||
console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC");
|
||||
const ourMb = ourPeak / MB;
|
||||
if (ourPeak > 0) {
|
||||
console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`);
|
||||
console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`");
|
||||
console.log(" during a deploy, which is the largest transient by some way.");
|
||||
} else {
|
||||
console.log(" The services are not running here, so nothing was measured. Run this ON");
|
||||
console.log(" the instance, with the backend up, for numbers that mean anything.");
|
||||
}
|
||||
console.log("");
|
||||
console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`);
|
||||
console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory");
|
||||
console.log(" and a little disk headroom are what matter. Run this again after a");
|
||||
console.log(" deploy and after a self-update to catch the peaks rather than the calm.");
|
||||
};
|
||||
|
||||
// Run when invoked, importable when tested. The suite exercises dirSize and
|
||||
// diskUsage directly; printing a report on import would make that impossible.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report();
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — report the Dojo version of every listing.
|
||||
//
|
||||
// READ-ONLY. Nothing is written and no network is touched: it reads what the
|
||||
// updater has already recorded.
|
||||
//
|
||||
// Two versions per node, and the difference matters when choosing a minimum:
|
||||
//
|
||||
// detected from the node's own X-Dojo-Version header, read on every probe.
|
||||
// This is what it is actually running.
|
||||
// declared the version inside the pairing payload. Frozen when that payload
|
||||
// was generated and signed, so it can be years out of date while
|
||||
// the node itself is current. At least one listing here declares
|
||||
// 1.4.5 for exactly that reason.
|
||||
//
|
||||
// A minimum-version rule should therefore judge the DETECTED version. This
|
||||
// report shows both, so a threshold can be chosen against the real spread.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node check-versions.ts # against the configured minimum
|
||||
// node check-versions.ts 1.27.0 # against a threshold you are weighing
|
||||
// =============================================================================
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { store } from "./store.ts";
|
||||
import { MIN_DOJO_VERSION, judgeVersion, compareVersions } from "./dojo-version.ts";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
|
||||
const minimum = (process.argv.find((a) => /^\d/.test(a)) || MIN_DOJO_VERSION || "1.27.0").trim();
|
||||
|
||||
const dojos = await readFile(path.join(PUBLIC_DIR, "dojos.json"), "utf8")
|
||||
.then((t) => JSON.parse(t)).catch(() => ({ nodes: [] }));
|
||||
const published = new Map((dojos.nodes || []).map((n: any) => [n.id, n]));
|
||||
const records = (await store.listSubmissions())
|
||||
.filter((r) => r.status === "approved")
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const rows = records.map((r) => {
|
||||
const pub: any = published.get(r.id) || {};
|
||||
const detected = pub.detected_version || null;
|
||||
const declared = r.payload?.pairing?.version || null;
|
||||
const verdict = judgeVersion(detected, declared, minimum);
|
||||
return { id: r.id, name: r.name || r.id, detected, declared, verdict, status: pub.status || "?" };
|
||||
});
|
||||
|
||||
const pad = (s: string, n: number) => (s || "").padEnd(n);
|
||||
console.log(`Minimum being applied: ${minimum}\n`);
|
||||
console.log(pad("RECORD", 30) + pad("DETECTED", 12) + pad("DECLARED", 12) + pad("NODE", 10) + "VERDICT");
|
||||
console.log("-".repeat(78));
|
||||
for (const r of rows) {
|
||||
const v = r.verdict.ok ? "ok" : (r.verdict.version ? "BELOW MINIMUM" : "no version reported");
|
||||
console.log(pad(r.id, 30) + pad(r.detected || "—", 12) + pad(r.declared || "—", 12) + pad(r.status, 10) + v);
|
||||
}
|
||||
|
||||
const below = rows.filter((r) => !r.verdict.ok && r.verdict.version);
|
||||
const unknown = rows.filter((r) => !r.verdict.ok && !r.verdict.version);
|
||||
const ok = rows.length - below.length - unknown.length;
|
||||
|
||||
console.log(`\n${ok} at or above ${minimum}, ${below.length} below, ${unknown.length} with no version reported.`);
|
||||
if (below.length) {
|
||||
console.log("\nBelow the minimum:");
|
||||
for (const r of below) console.log(` ${r.id}: ${r.verdict.version} (${r.verdict.source})`);
|
||||
}
|
||||
if (unknown.length) {
|
||||
console.log("\nNo version reported. A node that has never been probed successfully shows nothing here,");
|
||||
console.log("so check whether these are down rather than old before reading anything into it:");
|
||||
for (const r of unknown) console.log(` ${r.id} (node currently ${r.status})`);
|
||||
}
|
||||
|
||||
// The spread, which is what a threshold should actually be chosen against.
|
||||
const seen = rows.map((r) => r.detected).filter(Boolean) as string[];
|
||||
if (seen.length) {
|
||||
const uniq = [...new Set(seen)].sort(compareVersions);
|
||||
console.log(`\nDetected versions in use: ${uniq.join(", ")}`);
|
||||
console.log(`Oldest running: ${uniq[0]}. A minimum above that would refuse a node currently listed,`);
|
||||
console.log("though existing listings are never re-judged — the check applies to new submissions.");
|
||||
}
|
||||
process.exit(below.length || unknown.length ? 1 : 0);
|
||||
@@ -0,0 +1,429 @@
|
||||
// Auth47 login and BIP47 signed-payload verification for The Dojo Bay backend.
|
||||
// Thin wrappers over the audited Samourai libraries; the exact call shapes here
|
||||
// were verified against the libraries end to end (see selftest.mjs).
|
||||
import { Auth47Verifier } from "@dojo-tools/auth47";
|
||||
import { BIP47Factory } from "@dojo-tools/bip47";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
/** Outcome of a signature check: either accepted, or refused with a reason an
|
||||
* operator can act on. */
|
||||
export type VerifyResult =
|
||||
| { ok: true; error?: undefined; address?: string; paymentCode?: string | null }
|
||||
| { ok: false; error: string; address?: undefined; paymentCode?: undefined };
|
||||
|
||||
/** The parts of a wallet-exported signed block. */
|
||||
export interface ParsedBlock {
|
||||
/** Everything the signature covers, including the BIP47 tail. */
|
||||
message: string;
|
||||
/** The pairing JSON alone. */
|
||||
pairingText: string;
|
||||
/** The payment code inside the signed text, when present. */
|
||||
paymentCode: string | null;
|
||||
address: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const bip47 = BIP47Factory(ecc);
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
|
||||
// ---- Auth47 ----------------------------------------------------------------
|
||||
// The verifier needs to know its own callback URL. We build it from the site's
|
||||
// base URL (the .onion origin) at construction time.
|
||||
export function makeAuth47(baseUrl) {
|
||||
const callback = new URL("/api/auth47/callback", baseUrl).toString();
|
||||
const verifier = new Auth47Verifier(ecc, callback);
|
||||
|
||||
// Full challenge URI shown to the wallet (includes the callback `c`).
|
||||
function challengeURI(nonce, expires, resource) {
|
||||
return verifier.generateURI({ nonce, expires, resource });
|
||||
}
|
||||
|
||||
// Per the spec, the wallet signs the challenge WITHOUT the callback param.
|
||||
// Given the full URI we generated, produce the value the proof must contain.
|
||||
function signedForm(fullUri) {
|
||||
const u = new URL(fullUri);
|
||||
u.searchParams.delete("c");
|
||||
return decodeURIComponent(u.toString());
|
||||
}
|
||||
|
||||
// Two URLs naming the same resource. Compared as parsed URLs rather than as
|
||||
// strings, so a trailing slash or a difference in host case is not treated as
|
||||
// a different site, while a different origin or path is. Anything that does
|
||||
// not parse is not equal to anything.
|
||||
function sameResource(a: string, b: string): boolean {
|
||||
try {
|
||||
const norm = (u: string) => {
|
||||
const x = new URL(u);
|
||||
return x.origin.toLowerCase() + x.pathname.replace(/\/+$/, "") + x.search;
|
||||
};
|
||||
return norm(a) === norm(b);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Verify a posted proof. Returns { ok, paymentCode } or { ok:false, error }.
|
||||
//
|
||||
// expectedResource is REQUIRED, and the shape is the point. A signature is
|
||||
// only ever evidence of what it was made over, so a verifier that takes only
|
||||
// the thing being verified can answer "is this signed?" but never "is this
|
||||
// signed FOR ME?". The other three verifiers in this file all take an
|
||||
// expectation for that reason: verifySignedPayload takes expectedMessage and
|
||||
// expectedAddress, verifySignedUrlClaim takes expectedUrl, verifyOperatorDoc
|
||||
// takes expectedOnion. This one did not, and the missing binding was
|
||||
// invisible rather than a missing argument.
|
||||
//
|
||||
// What it prevents: the library checks that the challenge's r parameter is a
|
||||
// well-formed http(s) URL, but it cannot know which URL is ours. Without this
|
||||
// comparison an attacker could take a live nonce from this instance, show a
|
||||
// victim the same challenge with r rewritten to their own site, and relay the
|
||||
// resulting proof back here. The victim's wallet would display the attacker's
|
||||
// site, the signature would verify, and a session would be minted here in the
|
||||
// victim's name. The r parameter exists so a person can see what they are
|
||||
// signing into, and this check is what makes that display mean anything.
|
||||
function verify(proof: unknown, { expectedResource }: { expectedResource?: string } = {}): VerifyResult {
|
||||
// Fail closed rather than throwing: a caller who forgot this is a bug, but
|
||||
// a 500 from an auth endpoint is a worse way to find out than a refusal
|
||||
// that names the omission.
|
||||
if (!expectedResource) {
|
||||
return { ok: false, error: "internal: no expected resource supplied, refusing to verify an unbound proof" };
|
||||
}
|
||||
const res = verifier.verifyProof(proof);
|
||||
if (res.result !== "ok") return { ok: false, error: res.error };
|
||||
// Read the resource from the challenge the signature actually covers, not
|
||||
// from anything the caller passed alongside it.
|
||||
const challenge = (proof as { challenge?: unknown }).challenge;
|
||||
let resource: string | null = null;
|
||||
try { resource = new URL(String(challenge)).searchParams.get("r"); } catch { /* unparseable */ }
|
||||
if (!resource || !sameResource(resource, expectedResource)) {
|
||||
return { ok: false, error: `proof was signed for a different site (${resource || "no resource"}), not this one` };
|
||||
}
|
||||
// Auth47 defines two proof shapes: a nym proof carrying a payment code, and
|
||||
// an address proof carrying a plain address. Only the former identifies an
|
||||
// operator here, and reading .nym off the wrong one would bind a session to
|
||||
// undefined, so require it explicitly rather than assuming.
|
||||
const nym = (res.data as { nym?: string }).nym;
|
||||
if (typeof nym !== "string" || !nym) {
|
||||
return { ok: false, error: "proof does not carry a payment code (an address proof cannot identify an operator)" };
|
||||
}
|
||||
return { ok: true, paymentCode: nym };
|
||||
}
|
||||
|
||||
return { challengeURI, signedForm, verify, callback };
|
||||
}
|
||||
|
||||
// ---- payment code -> notification address ----------------------------------
|
||||
export function notificationAddress(paymentCode: string, network: string = "bitcoin"): string {
|
||||
const net = bip47utils.networks[network];
|
||||
return bip47.fromBase58(paymentCode, net).getNotificationAddress();
|
||||
}
|
||||
|
||||
// The exact text an operator signs to attest to a pairing payload, and the
|
||||
// exact text every gate checks a signature against.
|
||||
//
|
||||
// It lives here because it had grown two copies, in the submission gate and in
|
||||
// audit-signed.mjs, the second carrying a comment warning that it MUST mirror
|
||||
// the first. A canonical message that exists twice is a canonical message
|
||||
// waiting to disagree with itself, and the failure would be quiet in the worst
|
||||
// direction: signatures accepted at submission and reported as invalid by a
|
||||
// later audit, or the reverse. The installer needs it too, which would have
|
||||
// made three.
|
||||
export function canonicalPairing(payload: { pairing?: unknown; explorer?: unknown } | null | undefined): string {
|
||||
return JSON.stringify({ pairing: payload?.pairing, explorer: payload?.explorer });
|
||||
}
|
||||
|
||||
// Every address a given payment code could legitimately have signed from.
|
||||
// A PayNym is a MAINNET identity: an operator listing a testnet node still
|
||||
// signs with their mainnet notification address, because that is the only key
|
||||
// their wallet holds for that code. Deriving on testnet yields an "m…" address
|
||||
// that can never match, which silently made every testnet listing unverifiable.
|
||||
// Both derivations come from the same code, so accepting either is no weaker.
|
||||
export function notificationAddresses(paymentCode: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const net of ["bitcoin", "testnet"]) {
|
||||
try { const a = notificationAddress(paymentCode, net); if (!out.includes(a)) out.push(a); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- lab-style signed pairing payload verification -------------------------
|
||||
// The submitted `signed` blob is a BIP-signed message. We require it to be
|
||||
// signed by the notification address of the operator's authenticated payment
|
||||
// code, over the exact pairing JSON they are submitting. This is the same
|
||||
// verify() the paymentcode.io lab uses.
|
||||
//
|
||||
// The signed message format Samourai/Ashigaru export wraps the payload between
|
||||
// BEGIN/END markers. CRITICAL, verified against a real wallet export: the text
|
||||
// the wallet signs is EVERYTHING between the markers, i.e. the pairing JSON
|
||||
// PLUS the trailing "BIP47:" line and payment code (no trailing newline). An
|
||||
// earlier revision excised the BIP47 tail before verifying, which made every
|
||||
// genuine wallet signature fail as "invalid signature"; the selftest did not
|
||||
// catch it because it constructed its own blocks under the same assumption.
|
||||
// Because the BIP47 line is inside the signed text, the payment code is
|
||||
// covered by the signature and can itself be verified against the signing
|
||||
// address (see verifySignedPayload).
|
||||
// Repair a signed block whose whitespace was mangled in transit.
|
||||
//
|
||||
// The signature covers the exact bytes between the markers, and the blank line
|
||||
// before the "BIP47:" line is part of them. Copying a block through a chat
|
||||
// window, a web form or a mail client routinely collapses that blank line, at
|
||||
// which point a perfectly good signature stops verifying and the operator is
|
||||
// told their signature is invalid, which is both wrong and unhelpful.
|
||||
//
|
||||
// This is safe rather than a fudge: a candidate is accepted ONLY if it verifies
|
||||
// cryptographically against an address the declared payment code derives, so
|
||||
// nothing is taken on trust. The repaired block is what gets stored, so later
|
||||
// audits verify too. Returns null when no candidate verifies.
|
||||
export function repairSignedBlock(text: unknown): { block: string; note: string | null } | null {
|
||||
const raw = String(text || "").replace(/\r\n/g, "\n");
|
||||
const addrM = raw.match(/Address:\s*(\S+)/);
|
||||
const sigM = raw.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
const innerM = raw.match(/SIGNED MESSAGE-----[ \t]*\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
if (!addrM || !sigM || !innerM) return null;
|
||||
const address = addrM[1].trim(), signature = sigM[1].trim();
|
||||
const inner = innerM[1];
|
||||
const codeM = inner.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
const json = inner.replace(/\n*[ \t]*BIP47:[\s\S]*$/, "").replace(/\n+$/, "");
|
||||
const code = codeM ? codeM[1] : null;
|
||||
|
||||
const candidates: [string, string][] = [["", inner]];
|
||||
if (code) {
|
||||
candidates.push(
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47: ${code}`],
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47:\n${code}`],
|
||||
);
|
||||
}
|
||||
const accept = code ? notificationAddresses(code) : [];
|
||||
if (code && !accept.includes(address)) return null; // the code does not own this address
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
for (const [note, candidate] of candidates) {
|
||||
let ok = false;
|
||||
try { ok = message.verify(candidate, address, signature, net.messagePrefix); } catch { ok = false; }
|
||||
if (!ok) continue;
|
||||
const block = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${candidate}\n` +
|
||||
`-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${address}\n\n${signature}\n` +
|
||||
`-----END BITCOIN SIGNATURE-----`;
|
||||
return { block, note: note || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSignedBlock(text: unknown): ParsedBlock | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const t = text.replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+/=]{80,})\n-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) return null;
|
||||
const message = msgM[1].trim(); // the full signed text
|
||||
const tail = message.match(/^([\s\S]*?)\n\s*BIP47:\s*\n?(\S+)$/);
|
||||
return {
|
||||
message, // what the signature covers
|
||||
pairingText: tail ? tail[1].trim() : message, // the pairing JSON alone
|
||||
paymentCode: tail ? tail[2] : null, // code inside the signed text
|
||||
address: addrM[1].trim(),
|
||||
signature: sigM[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify a signed pairing block. Checks, in order, with distinct errors:
|
||||
// 1. the block parses at all;
|
||||
// 2. the pairing JSON inside it matches the payload being submitted;
|
||||
// 3. the signature is cryptographically valid over the FULL signed text;
|
||||
// 4. (signature now known valid) the BIP47 payment code inside the signed
|
||||
// text is a valid code whose notification address IS the signing address;
|
||||
// 5. the signing address matches the authenticated payment code's
|
||||
// notification address (the session binding the API supplies).
|
||||
// Does the signed pairing text describe the same payload being submitted?
|
||||
//
|
||||
// Wallets and admin panels serialise this JSON differently: pretty-printed with
|
||||
// newlines and indentation, or with the object keys in another order. All of
|
||||
// those are the SAME payload, and a byte-exact comparison against our own
|
||||
// re-serialisation rejects them, which is what made genuine, correctly signed
|
||||
// listings fail the gate. So compare the parsed structures instead: identical
|
||||
// keys and identical values, order-insensitive, at every level. Anything that
|
||||
// is not valid JSON, or that differs in any value or key, still fails.
|
||||
export function sameSignedPayload(signedText: string, expected: string): boolean {
|
||||
const a = String(signedText).trim(), b = String(expected).trim();
|
||||
if (a === b) return true;
|
||||
let pa, pb;
|
||||
try { pa = JSON.parse(a); pb = JSON.parse(b); } catch { return false; }
|
||||
return stableStringify(pa) === stableStringify(pb);
|
||||
}
|
||||
|
||||
function stableStringify(v: unknown): string {
|
||||
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v) ?? "null";
|
||||
}
|
||||
|
||||
export function verifySignedPayload({ signedText, expectedMessage, expectedAddress, network = "bitcoin" }: {
|
||||
signedText: string;
|
||||
expectedMessage?: string | null;
|
||||
expectedAddress?: string | string[] | null;
|
||||
network?: string;
|
||||
}): VerifyResult {
|
||||
const parsed = parseSignedBlock(signedText);
|
||||
if (!parsed) return { ok: false, error: "unrecognised signed message format" };
|
||||
if (expectedMessage != null && !sameSignedPayload(parsed.pairingText, expectedMessage)) {
|
||||
return { ok: false, error: "signed message does not match the submitted pairing code" };
|
||||
}
|
||||
const net = bip47utils.networks[network];
|
||||
let verified = false;
|
||||
try {
|
||||
verified = message.verify(parsed.message, parsed.address, parsed.signature, net.messagePrefix);
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
if (!verified) return { ok: false, error: "invalid signature" };
|
||||
if (parsed.paymentCode) {
|
||||
const derived = notificationAddresses(parsed.paymentCode);
|
||||
if (!derived.length) {
|
||||
return { ok: false, error: "signature is valid, but the BIP47 line inside the signed message is not a valid payment code" };
|
||||
}
|
||||
if (!derived.includes(parsed.address)) {
|
||||
return { ok: false, error: "signature is valid, but the signing address is not the notification address of the payment code inside the message" };
|
||||
}
|
||||
}
|
||||
// expectedAddress may be a single address or every address the authenticated
|
||||
// code could have signed from (see notificationAddresses).
|
||||
const accept = expectedAddress == null ? null : (Array.isArray(expectedAddress) ? expectedAddress : [expectedAddress]);
|
||||
if (accept && !accept.includes(parsed.address)) {
|
||||
return { ok: false, error: "signed by a different address than the authenticated payment code" };
|
||||
}
|
||||
return { ok: true, address: parsed.address, paymentCode: parsed.paymentCode };
|
||||
}
|
||||
|
||||
// ---- operator binding (data/operator.json) ----------------------------------
|
||||
// A Dojo Bay instance MUST prove who runs it: operator.json binds the onion
|
||||
// address to the operator's payment code via a wallet signature over the text
|
||||
//
|
||||
// http://<onion>/
|
||||
//
|
||||
// BIP47: <payment code>
|
||||
//
|
||||
// (unlike pairing blocks, the BIP47 line here is INSIDE the signed message:
|
||||
// the operator pastes the whole text into the wallet's Sign tool). Verified at
|
||||
// install, at bootstrap import before trusting a remote instance's data, and
|
||||
// on every rebuild.
|
||||
// ---- signed URL claims -----------------------------------------------------
|
||||
// A verified operator domain is proven the same way the instance's own onion is:
|
||||
// the operator signs the URL, a blank line, then "BIP47: <their code>". Same
|
||||
// shape, same wallet procedure (PayNym → Sign message), so nothing new to learn
|
||||
// and no new crypto. This is deliberately a separate field from the pairing
|
||||
// payload: the pairing block attests to pairing data only, and operators
|
||||
// stuffing identity material into it is exactly what this feature replaces.
|
||||
export function claimText(url: string, paymentCode: string): string {
|
||||
return `${String(url).replace(/\/+$/, "")}/\n\nBIP47: ${paymentCode}`;
|
||||
}
|
||||
|
||||
// Verify a signed claim over `expectedUrl` by `paymentCode`. Returns
|
||||
// { ok } or { ok: false, error } with errors an operator can act on.
|
||||
export function verifySignedUrlClaim({ signed, expectedUrl, paymentCode }: {
|
||||
signed: string;
|
||||
expectedUrl: string;
|
||||
paymentCode: string;
|
||||
}): VerifyResult {
|
||||
if (!signed) return { ok: false, error: "no signed block supplied" };
|
||||
if (!paymentCode) return { ok: false, error: "no payment code supplied" };
|
||||
const t = String(signed).replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `not a recognisable signed block (missing ${missing}) — the paste may have been truncated` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(expectedUrl)) {
|
||||
return { ok: false, error: `the signed message starts with ${firstLine || "(nothing)"}, but this claim is for ${expectedUrl}` };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM) return { ok: false, error: "the signed message has no BIP47: line" };
|
||||
if (bipM[1] !== paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message is a different payment code from the one you are signed in with" };
|
||||
}
|
||||
const accept = notificationAddresses(paymentCode);
|
||||
if (!accept.includes(addrM[1].trim())) {
|
||||
return { ok: false, error: `signed by ${addrM[1].trim()}, but your payment code's notification address is ${accept[0]} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
try {
|
||||
if (!message.verify(signedMessage, addrM[1].trim(), sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
return { ok: true, address: addrM[1].trim() };
|
||||
}
|
||||
|
||||
export function verifyOperatorDoc(doc: any, { expectedOnion }: { expectedOnion?: string } = {}): VerifyResult {
|
||||
if (!doc || typeof doc !== "object") return { ok: false, error: "operator.json missing or unreadable" };
|
||||
if (!doc.paymentCode) return { ok: false, error: "operator.json has no paymentCode" };
|
||||
if (!doc.verifySigned) return { ok: false, error: "operator.json has no verifySigned block" };
|
||||
const t = String(doc.verifySigned).replace(/\r\n/g, "\n");
|
||||
// The newline after the BEGIN marker is optional: some terminals swallow it
|
||||
// when a block is pasted. It is not part of the signed text either way, so
|
||||
// tolerating it recovers the correct message rather than changing it.
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
// Name what is missing: a truncated or line-dropped paste is by far the
|
||||
// most common cause, and "not recognisable" alone sends people hunting
|
||||
// for a problem with their wallet instead of re-pasting.
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `verifySigned is not a recognisable signed block (missing ${missing}) — the paste may have been truncated; paste the whole block again` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "");
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(doc.onion)) return { ok: false, error: "signed message does not match the declared onion" };
|
||||
if (expectedOnion && norm(doc.onion) !== norm(expectedOnion)) {
|
||||
return { ok: false, error: "declared onion does not match the address this document was fetched from" };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM || bipM[1] !== doc.paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message does not match the declared payment code" };
|
||||
}
|
||||
// Accept either derivation of the notification address.
|
||||
//
|
||||
// A PayNym is a mainnet identity, but a wallet running on testnet derives the
|
||||
// notification address for THAT network, so the same payment code signs from
|
||||
// a different address depending on which mode the operator's wallet is in.
|
||||
// Insisting on the mainnet form refused perfectly good bindings from anyone
|
||||
// running a testnet wallet — the same defect fixed for listing signatures,
|
||||
// which this path missed.
|
||||
const accept = notificationAddresses(doc.paymentCode);
|
||||
const signer = addrM[1].trim();
|
||||
if (!accept.includes(signer)) {
|
||||
// Naming the addresses matters: the usual cause is signing from a different
|
||||
// account than the payment code entered, and the operator can only spot
|
||||
// that if they can see which address their wallet actually used.
|
||||
const expected = accept.length > 1
|
||||
? `${accept[0]} on mainnet, or ${accept[1]} from a testnet wallet`
|
||||
: accept[0] || "(the code could not be decoded)";
|
||||
return { ok: false, error: `signed by ${signer}, but the payment code's notification address is ${expected} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin; // the message prefix is the same on both
|
||||
try {
|
||||
if (!message.verify(signedMessage, signer, sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) { return { ok: false, error: "signature could not be verified (" + e.message + ")" }; }
|
||||
return { ok: true, address: signer };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — diagnose signed-block mismatches.
|
||||
//
|
||||
// READ-ONLY. For every record whose stored block does not pass the gate, this
|
||||
// answers the question the audit cannot: is the SIGNATURE bad, or is the stored
|
||||
// payload merely a different representation of the same signed text?
|
||||
//
|
||||
// For each record it reports, in order:
|
||||
// 1. INTERNAL VALIDITY - does the signature verify over the block's own text,
|
||||
// and does the BIP47 code inside that text derive the signing address?
|
||||
// If yes, the block is a genuine wallet export and nothing is forged.
|
||||
// 2. MESSAGE MATCH - does the pairing JSON inside the block equal
|
||||
// canonicalPairing(stored payload) byte for byte? If not, it shows the
|
||||
// first differing offset with a window either side, and whether the two
|
||||
// are the same DATA in a different serialisation (key order, spacing) or
|
||||
// genuinely different values.
|
||||
//
|
||||
// Run on the box: cd /var/www/dojobay/server && node diagnose-signed.mjs
|
||||
// Add --all to include records that already pass.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
// canonicalPairing is imported, never reimplemented: a diagnostic that computes
|
||||
// the canonical message its own way can only ever report on itself.
|
||||
// server/selftest.mjs enforces the single definition.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
const ALL = process.argv.includes("--all");
|
||||
const netOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Same data, different serialisation? Compare parsed structures, not strings.
|
||||
const deepEq = (a, b) => {
|
||||
try { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); } catch { return false; }
|
||||
};
|
||||
const sortDeep = (v) => {
|
||||
if (Array.isArray(v)) return v.map(sortDeep);
|
||||
if (v && typeof v === "object") {
|
||||
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])]));
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
function firstDiff(a, b) {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
|
||||
return a.length === b.length ? -1 : n;
|
||||
}
|
||||
const window_ = (s, i) => JSON.stringify(s.slice(Math.max(0, i - 30), i + 30));
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
let genuine = 0, forged = 0, drifted = 0, reorder = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
if (!rec.signed) continue;
|
||||
const net = netOf(rec);
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
const canon = canonicalPairing(rec.payload);
|
||||
const passes = codes.some((c) => {
|
||||
try { return verifySignedPayload({ signedText: rec.signed, expectedMessage: canon, expectedAddress: notificationAddresses(c), network: net }).ok; }
|
||||
catch { return false; }
|
||||
});
|
||||
if (passes && !ALL) continue;
|
||||
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(`\n=== ${label} (${rec.status})${passes ? " [currently passes]" : ""}`);
|
||||
const p = parseSignedBlock(rec.signed);
|
||||
if (!p) { console.log(" block does not parse at all"); continue; }
|
||||
|
||||
// 1. internal validity
|
||||
let sigOk = false;
|
||||
try { sigOk = message.verify(p.message, p.address, p.signature, bip47utils.networks[net].messagePrefix); } catch (e) { console.log(" verify threw:", e.message); }
|
||||
// A PayNym signs from its mainnet notification address whatever network the
|
||||
// node is on, so both derivations are legitimate.
|
||||
let derived = null;
|
||||
try { derived = p.paymentCode ? notificationAddresses(p.paymentCode) : null; } catch { derived = null; }
|
||||
const bound = Array.isArray(derived) && derived.includes(p.address);
|
||||
const derivedTxt = Array.isArray(derived) ? derived.join(" / ") : "(undecodable)";
|
||||
console.log(` signature over the block's own text : ${sigOk ? "VALID" : "INVALID"}`);
|
||||
console.log(` signing address : ${p.address}`);
|
||||
console.log(` BIP47 code inside the signed text : ${p.paymentCode ? p.paymentCode.slice(0, 12) + "…" : "(none)"} -> ${p.paymentCode ? derivedTxt : "n/a"} ${p.paymentCode ? (bound ? "(binds)" : "(DOES NOT BIND)") : ""}`);
|
||||
console.log(` record's payment code(s) : ${codes.map((c) => c.slice(0, 12) + "…").join(", ") || "(none)"}`);
|
||||
if (sigOk && bound) genuine++; else forged++;
|
||||
|
||||
// 2. message match
|
||||
if (p.pairingText === canon) { console.log(" pairing text matches the stored payload exactly"); continue; }
|
||||
const i = firstDiff(p.pairingText, canon);
|
||||
let signedObj, storedObj;
|
||||
try { signedObj = JSON.parse(p.pairingText); } catch {}
|
||||
try { storedObj = JSON.parse(canon); } catch {}
|
||||
const same = signedObj && storedObj && deepEq(signedObj, storedObj);
|
||||
if (same) reorder++; else drifted++;
|
||||
console.log(` pairing text DIFFERS from the stored payload`);
|
||||
console.log(` same data, different serialisation? ${same ? "YES - key order/spacing only" : "NO - the values themselves differ"}`);
|
||||
console.log(` lengths: signed ${p.pairingText.length}, stored ${canon.length}; first difference at offset ${i}`);
|
||||
console.log(` signed: ${window_(p.pairingText, i)}`);
|
||||
console.log(` stored: ${window_(canon, i)}`);
|
||||
if (!same && signedObj && storedObj) {
|
||||
const keys = new Set([...Object.keys(signedObj), ...Object.keys(storedObj)]);
|
||||
for (const k of keys) {
|
||||
if (JSON.stringify(sortDeep(signedObj[k])) !== JSON.stringify(sortDeep(storedObj[k]))) {
|
||||
console.log(` top-level key "${k}" differs:`);
|
||||
console.log(` signed: ${JSON.stringify(signedObj[k])}`);
|
||||
console.log(` stored: ${JSON.stringify(storedObj[k])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary of blocks examined: ${genuine} genuine (valid signature, code binds), ${forged} not genuine.`);
|
||||
console.log(`Mismatches: ${reorder} serialisation-only, ${drifted} with genuinely different values.`);
|
||||
console.log(genuine && !forged
|
||||
? "\nEvery block examined is a real wallet export; the failures are a stored-payload representation problem, not a trust problem."
|
||||
: "");
|
||||
@@ -0,0 +1,182 @@
|
||||
// =============================================================================
|
||||
// TXT record lookups over Tor, for verified operator domains.
|
||||
//
|
||||
// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS
|
||||
// interface resolves names but cannot fetch arbitrary record types. So we ask
|
||||
// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS
|
||||
// proxy the probes use.
|
||||
//
|
||||
// Two deliberate choices, because a resolver's answer decides whether a
|
||||
// verified badge appears and a lying resolver could mint one:
|
||||
//
|
||||
// 1. Several independent resolvers are queried and a fixed number must agree
|
||||
// before a domain is treated as verified (DOH_AGREEMENT, default 2).
|
||||
// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as
|
||||
// a failure, so a Tor hiccup cannot strip a badge from an honest operator.
|
||||
//
|
||||
// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose:
|
||||
// that module is on the self-update path, which has never been exercised on real
|
||||
// hardware, and refactoring it to share code is not a risk worth taking for a
|
||||
// feature that only reads DNS.
|
||||
// =============================================================================
|
||||
import tls from "node:tls";
|
||||
import { socks5Connect } from "../scripts/update.mjs";
|
||||
import type { ProbeCfg } from "../types.js";
|
||||
|
||||
/** Transport settings a lookup needs; the caller may supply a subset. */
|
||||
type LookupCfg = Partial<ProbeCfg> & {
|
||||
/**
|
||||
* An extra certificate authority to trust for this lookup, and nothing else.
|
||||
*
|
||||
* Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON
|
||||
* — against a mock resolver holding a self-signed certificate, WITHOUT
|
||||
* reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off
|
||||
* for the entire process and every other connection made while it is set.
|
||||
* Certificate validation stays on here; the test simply supplies the anchor
|
||||
* that makes its own certificate valid.
|
||||
*/
|
||||
tlsCa?: string | Buffer | Array<string | Buffer>;
|
||||
};
|
||||
|
||||
interface ResolverAnswer { host: string; records: string[] }
|
||||
export interface TxtLookup {
|
||||
records: string[];
|
||||
answered: number;
|
||||
byResolver: ResolverAnswer[];
|
||||
errors: string[];
|
||||
}
|
||||
export interface TxtAgreement {
|
||||
ok: boolean;
|
||||
/** True when too few resolvers replied to draw any conclusion. */
|
||||
inconclusive: boolean;
|
||||
/** Absent when the lookup threw before any resolver could be counted. */
|
||||
answered?: number;
|
||||
agreed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Resolvers use different JSON paths but the same response shape.
|
||||
const RESOLVERS: { host: string; path: string }[] = [
|
||||
{ host: "cloudflare-dns.com", path: "/dns-query" },
|
||||
{ host: "dns.quad9.net", path: "/dns-query" },
|
||||
{ host: "dns.google", path: "/resolve" },
|
||||
];
|
||||
|
||||
export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2));
|
||||
const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read
|
||||
|
||||
function resolverList(): { host: string; path: string }[] {
|
||||
const only = (process.env.DOH_RESOLVERS || "").trim();
|
||||
if (!only) return RESOLVERS;
|
||||
const wanted = only.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return RESOLVERS.filter((r) => wanted.includes(r.host));
|
||||
}
|
||||
|
||||
// One HTTPS GET through the Tor SOCKS proxy, returning the response body as
|
||||
// text. Deliberately minimal: no redirects (a resolver that redirects is not
|
||||
// one we want), and a hard body cap.
|
||||
async function httpsGetOverTor(host: string, path: string,
|
||||
{ proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise<string> {
|
||||
const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } };
|
||||
const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs);
|
||||
const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => {
|
||||
socket.write(
|
||||
`GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` +
|
||||
`Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
socket.on("data", (d: Buffer) => {
|
||||
size += d.length;
|
||||
if (size > MAX_BODY) return finish(reject, new Error("response too large"));
|
||||
chunks.push(d);
|
||||
});
|
||||
socket.on("error", (e: Error) => finish(reject, e));
|
||||
socket.on("close", () => {
|
||||
if (done) return;
|
||||
try {
|
||||
const all = Buffer.concat(chunks);
|
||||
const headEnd = all.indexOf("\r\n\r\n");
|
||||
if (headEnd < 0) return finish(reject, new Error("malformed reply"));
|
||||
const headText = all.subarray(0, headEnd).toString("latin1");
|
||||
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
if (!m) return finish(reject, new Error("malformed reply"));
|
||||
if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1]));
|
||||
let body = all.subarray(headEnd + 4);
|
||||
if (/transfer-encoding:\s*chunked/i.test(headText)) {
|
||||
const parts: Buffer[] = []; let p = 0;
|
||||
for (;;) {
|
||||
const nl = body.indexOf("\r\n", p);
|
||||
if (nl < 0) break;
|
||||
const n = parseInt(body.subarray(p, nl).toString("latin1"), 16);
|
||||
if (!n) break;
|
||||
parts.push(body.subarray(nl + 2, nl + 2 + n));
|
||||
p = nl + 2 + n + 2;
|
||||
}
|
||||
body = Buffer.concat(parts);
|
||||
}
|
||||
finish(resolve, body.toString("utf8"));
|
||||
} catch (e) { finish(reject, e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A DoH JSON answer gives TXT data as a quoted string, and a long record as
|
||||
// several quoted strings that must be concatenated. Normalise both to one line.
|
||||
export function parseTxtAnswer(json: string): string[] | null {
|
||||
let doc: any;
|
||||
try { doc = JSON.parse(json); } catch { return null; }
|
||||
if (typeof doc !== "object" || doc === null) return null;
|
||||
if (doc.Status === 3) return []; // NXDOMAIN: no records
|
||||
if (doc.Status !== 0) return null; // SERVFAIL etc: no answer
|
||||
const answers = Array.isArray(doc.Answer) ? doc.Answer : [];
|
||||
return answers
|
||||
.filter((a: any) => a && (a.type === 16 || a.type === undefined))
|
||||
.map((a: any) => String(a.data || ""))
|
||||
.map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d))
|
||||
.map((d: string) => d.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Look up TXT records for `name` across the resolvers. Returns
|
||||
// { records, answered, byResolver, errors } where `records` is the set of
|
||||
// records seen and `answered` counts resolvers that gave a usable answer.
|
||||
export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise<TxtLookup> {
|
||||
const resolvers = resolverList();
|
||||
const q = `?name=${encodeURIComponent(name)}&type=TXT`;
|
||||
const results = await Promise.allSettled(resolvers.map(async (r) => {
|
||||
const body = await httpsGetOverTor(r.host, r.path + q, cfg);
|
||||
const recs = parseTxtAnswer(body);
|
||||
if (recs === null) throw new Error("resolver returned no usable answer");
|
||||
return { host: r.host, records: recs };
|
||||
}));
|
||||
const byResolver: ResolverAnswer[] = [];
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]; // a local, so the union narrows
|
||||
if (r.status === "fulfilled") byResolver.push(r.value);
|
||||
else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`);
|
||||
}
|
||||
const records = [...new Set(byResolver.flatMap((r) => r.records))];
|
||||
return { records, answered: byResolver.length, byResolver, errors };
|
||||
}
|
||||
|
||||
// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`?
|
||||
// Distinguishes "not there" from "we could not tell".
|
||||
export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean,
|
||||
cfg: LookupCfg = {}): Promise<TxtAgreement> {
|
||||
const { answered, byResolver, errors, records } = await lookupTxt(name, cfg);
|
||||
if (answered < DOH_AGREEMENT) {
|
||||
return { ok: false, inconclusive: true, answered, agreed: 0,
|
||||
error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` };
|
||||
}
|
||||
const agreed = byResolver.filter((r) => r.records.some(predicate)).length;
|
||||
if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed };
|
||||
return { ok: false, inconclusive: false, answered, agreed,
|
||||
error: records.length
|
||||
? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching`
|
||||
: `no TXT record found at ${name}` };
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// =============================================================================
|
||||
// Dojo version comparison, and the minimum this directory will accept.
|
||||
//
|
||||
// A version reaches us two ways, and they are not equally trustworthy:
|
||||
//
|
||||
// detected read from the node's own X-Dojo-Version response header during a
|
||||
// probe. This is what the node is actually running.
|
||||
// declared the `version` inside the pairing payload. Informational, frozen
|
||||
// when the payload was generated, and often stale — one listing
|
||||
// here declares 1.4.5 while running something far newer, because
|
||||
// the payload was restored to match the signature that covers it.
|
||||
//
|
||||
// So anything deciding on a version prefers the detected one, and falls back to
|
||||
// the declared one only when the node did not report a header at all.
|
||||
// =============================================================================
|
||||
|
||||
// A country code inferred from whatever an operator wrote about where they are,
|
||||
// or nothing at all.
|
||||
//
|
||||
// The point is flags where we can manage them and no obligation anywhere else.
|
||||
// An operator is asked one free-text question and may answer "Finland", "FI",
|
||||
// "Central America", "Europe" or "Ancapistan"; the first two get a flag and the
|
||||
// rest do not, and none of them is an error. Nothing is enforced and nothing is
|
||||
// refused, because a directory of onion services has no business insisting that
|
||||
// somebody name a state.
|
||||
//
|
||||
// The names come from the runtime rather than a table in this repository.
|
||||
// Intl.DisplayNames knows 280 region codes and their English names, so the
|
||||
// lookup is current with the platform's ICU data instead of decaying in a file
|
||||
// nobody revisits. That also means an unassigned pair like XX yields nothing:
|
||||
// the runtime does not recognise it, so it cannot be a flag, and letterboxes on
|
||||
// a card read as a broken listing rather than a missing flag.
|
||||
const REGION_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
|
||||
|
||||
/** lowercased name -> code, built once from whatever the runtime knows. */
|
||||
const NAME_TO_CODE: Map<string, string> = (() => {
|
||||
const m = new Map<string, string>();
|
||||
for (let a = 65; a < 91; a++) {
|
||||
for (let b = 65; b < 91; b++) {
|
||||
const cc = String.fromCharCode(a, b);
|
||||
let name: string | undefined;
|
||||
try { name = REGION_NAMES.of(cc); } catch { continue; }
|
||||
if (name && name !== cc) m.set(name.toLowerCase(), cc);
|
||||
}
|
||||
}
|
||||
// The handful the runtime will not answer to, because people do not write
|
||||
// country names the way the standard does. UK is the one that matters: it is
|
||||
// not a code, and typed as one it renders as two letterboxes.
|
||||
for (const [alias, cc] of [
|
||||
["uk", "GB"], ["united kingdom", "GB"], ["great britain", "GB"], ["britain", "GB"],
|
||||
["england", "GB"], ["scotland", "GB"], ["wales", "GB"], ["northern ireland", "GB"],
|
||||
["usa", "US"], ["u.s.a.", "US"], ["u.s.", "US"], ["america", "US"],
|
||||
["holland", "NL"], ["czech republic", "CZ"], ["south korea", "KR"], ["north korea", "KP"],
|
||||
["russia", "RU"], ["uae", "AE"], ["eu", "EU"], ["european union", "EU"],
|
||||
]) m.set(alias, cc);
|
||||
return m;
|
||||
})();
|
||||
|
||||
export function countryFor(text: unknown): string | null {
|
||||
const raw = String(text ?? "").trim();
|
||||
if (!raw) return null;
|
||||
// Segments, so "Helsinki, Finland" and "Europe (Finland)" both find something.
|
||||
// Longest first: "United States" should win over a stray "US" elsewhere in
|
||||
// the same answer.
|
||||
const parts = raw.split(/[,;/()\u2013\u2014|]+/).map((x) => x.trim()).filter(Boolean);
|
||||
for (const part of [raw, ...parts].sort((a, b) => b.length - a.length)) {
|
||||
const key = part.toLowerCase().replace(/\.$/, "");
|
||||
const named = NAME_TO_CODE.get(key);
|
||||
if (named) return named;
|
||||
if (/^[a-z]{2}$/i.test(part)) {
|
||||
const cc = part.toUpperCase();
|
||||
// Only if the runtime recognises it: an unassigned pair has no flag, and
|
||||
// two letterboxes look like a fault rather than an absence.
|
||||
try { if (REGION_NAMES.of(cc) !== cc) return cc; } catch { /* not a region */ }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Which network a pairing URL is for, read from the URL itself.
|
||||
//
|
||||
// A Dojo serves its testnet API under a `test` path segment and its mainnet API
|
||||
// without one: http://<onion>/test/v2 against http://<onion>/v2. That makes the
|
||||
// operator's declared network checkable against the endpoint they gave, and it
|
||||
// is worth checking, because a crossed pair is wrong in a way nothing
|
||||
// downstream catches. A testnet node listed as mainnet answers, reports a
|
||||
// height and probes green indefinitely; the only symptom is a block height a
|
||||
// few hundred thousand adrift, which reads as nothing at all, and anyone
|
||||
// pairing with it is sent to a chain they did not ask for.
|
||||
//
|
||||
// A whole path SEGMENT, never a substring: an onion address is base32 and can
|
||||
// carry those four letters in a row by chance, and /v2/testing is not a testnet
|
||||
// endpoint either.
|
||||
//
|
||||
// It lives here rather than in the installer because the same judgement belongs
|
||||
// at the submission gate, and this module is already where a node's declared
|
||||
// properties are judged against what it actually is.
|
||||
export function pairingNetwork(url: string): "mainnet" | "testnet" | null {
|
||||
try {
|
||||
return new URL(url).pathname.split("/").some((seg) => seg.toLowerCase() === "test")
|
||||
? "testnet" : "mainnet";
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The lowest Dojo this directory will accept for a NEW listing. Set to "" or
|
||||
* "0" to disable the check entirely. Existing listings are never re-judged. */
|
||||
export const MIN_DOJO_VERSION = (process.env.MIN_DOJO_VERSION ?? "1.27.0").trim();
|
||||
|
||||
/** "v1.27.0-rc1" -> [1, 27, 0]. Null when there is no version in there at all. */
|
||||
export function parseVersion(v: unknown): number[] | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const m = v.trim().replace(/^v/i, "").match(/^(\d+(?:\.\d+)*)/);
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(".").map((n) => Number(n));
|
||||
return parts.every((n) => Number.isFinite(n)) ? parts : null;
|
||||
}
|
||||
|
||||
/** -1, 0 or 1. Missing components count as zero, so 1.27 equals 1.27.0. */
|
||||
export function compareVersions(a: unknown, b: unknown): number {
|
||||
const x = parseVersion(a) || [], y = parseVersion(b) || [];
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] || 0) - (y[i] || 0);
|
||||
if (d) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function meetsMinimum(version: unknown, minimum: string = MIN_DOJO_VERSION): boolean {
|
||||
if (!minimum || compareVersions(minimum, "0") === 0) return true; // check disabled
|
||||
return compareVersions(version, minimum) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a node's version for the submission gates.
|
||||
*
|
||||
* `unknown` is deliberately its own outcome rather than a silent pass or a
|
||||
* silent refusal: a node that reports no version at all is almost certainly too
|
||||
* old to carry the endpoints this directory reads, but saying so plainly is
|
||||
* more useful to an operator than either guessing.
|
||||
*/
|
||||
export function judgeVersion(
|
||||
detected: unknown, declared: unknown, minimum: string = MIN_DOJO_VERSION,
|
||||
): { ok: boolean; version: string | null; source: "detected" | "declared" | null; reason?: string } {
|
||||
if (!minimum || compareVersions(minimum, "0") === 0) {
|
||||
return { ok: true, version: (detected as string) || (declared as string) || null,
|
||||
source: detected ? "detected" : declared ? "declared" : null };
|
||||
}
|
||||
const version = (parseVersion(detected) ? detected : parseVersion(declared) ? declared : null) as string | null;
|
||||
const source = parseVersion(detected) ? "detected" as const : parseVersion(declared) ? "declared" as const : null;
|
||||
if (!version) {
|
||||
return { ok: false, version: null, source: null,
|
||||
reason: `this Dojo did not report a version, so it cannot be checked against the minimum of ${minimum}. `
|
||||
+ "Dojo has sent an X-Dojo-Version header on every response since well before that, so a node that "
|
||||
+ "sends none is almost certainly older. Upgrade, then submit again." };
|
||||
}
|
||||
if (!meetsMinimum(version, minimum)) {
|
||||
return { ok: false, version, source,
|
||||
reason: `this Dojo reports version ${version}, and this directory requires ${minimum} or newer. `
|
||||
+ "Earlier versions do not serve the endpoints listings are checked against. Upgrade, then submit again." };
|
||||
}
|
||||
return { ok: true, version, source };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// =============================================================================
|
||||
// Verified operator domains.
|
||||
//
|
||||
// One domain per operator, bound to their BIP47 payment code and proven in both
|
||||
// directions, so neither half alone is enough:
|
||||
//
|
||||
// the domain asserts the code a TXT record at _dojobay.<domain> naming the
|
||||
// payment code; publishing it needs control of
|
||||
// the domain
|
||||
// the code asserts the domain a wallet-signed statement naming the domain;
|
||||
// producing it needs the PayNym's notification
|
||||
// key
|
||||
//
|
||||
// The signature is permanent and the TXT record is the revocable half. Remove
|
||||
// the record and the next sweep fails; after a grace period the badge drops,
|
||||
// while the claim is kept so restoring the record restores the badge without
|
||||
// re-signing. A domain that changes hands therefore stops being claimable by
|
||||
// its old owner without anyone having to notice.
|
||||
//
|
||||
// The signed statement deliberately omits this instance's onion, so a proof is
|
||||
// portable: a bootstrap import or peer sync carries it intact.
|
||||
//
|
||||
// A verified badge attests to CONTROL of a domain, not to trustworthiness: a
|
||||
// lookalike domain verifies exactly as easily as a real one. Hence admin
|
||||
// revocation, and punycode display for anything non-ASCII.
|
||||
// =============================================================================
|
||||
import { claimText, verifySignedUrlClaim } from "./crypto.ts";
|
||||
import { txtRecordAgreed } from "./dns.ts";
|
||||
import type { TxtAgreement } from "./dns.ts";
|
||||
import type { DomainClaim, ProbeCfg } from "../types.js";
|
||||
|
||||
type LookupCfg = Partial<ProbeCfg>;
|
||||
|
||||
/** A normalised domain, or the reason the input could not be one. */
|
||||
export type NormalisedDomain =
|
||||
| { ok: true; domain: string; punycode: boolean; error?: undefined }
|
||||
| { ok: false; error: string; domain?: undefined; punycode?: undefined };
|
||||
|
||||
export interface ClaimVerification {
|
||||
ok: boolean;
|
||||
stage?: "signature" | "dns";
|
||||
error?: string;
|
||||
hint?: string | null;
|
||||
inconclusive?: boolean;
|
||||
agreed?: number;
|
||||
answered?: number;
|
||||
}
|
||||
|
||||
export const TXT_PREFIX = "_dojobay";
|
||||
export const CLAIM_VERSION = "dojobay-domain-v1";
|
||||
export const RECHECK_MS = +(process.env.DOMAIN_RECHECK_HOURS || 24) * 3600 * 1000;
|
||||
/** A claim awaiting its first successful lookup is retried far more often: the
|
||||
* operator has just published a TXT record and is waiting for propagation. */
|
||||
export const PENDING_RECHECK_MS = +(process.env.DOMAIN_PENDING_RECHECK_MINUTES || 5) * 60 * 1000;
|
||||
export const GRACE_DAYS = +(process.env.DOMAIN_GRACE_DAYS || 7);
|
||||
|
||||
// Accept "example.com", "example.com/", "https://example.com" or a full URL and
|
||||
// reduce it to the bare ASCII host. Rejects anything that cannot be a public
|
||||
// domain an operator could publish a TXT record on.
|
||||
export function normaliseDomain(input: unknown): NormalisedDomain {
|
||||
let raw = String(input || "").trim().toLowerCase();
|
||||
if (!raw) return { ok: false, error: "enter a domain" };
|
||||
if (raw.includes(" ")) return { ok: false, error: "a domain cannot contain spaces" };
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//.test(raw)) raw = "https://" + raw;
|
||||
let u: URL;
|
||||
try { u = new URL(raw); } catch { return { ok: false, error: "that is not a valid domain" }; }
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, error: "use a plain domain, not a " + u.protocol.replace(":", "") + " URL" };
|
||||
if (u.username || u.password) return { ok: false, error: "a domain cannot contain a username or password" };
|
||||
if (u.port) return { ok: false, error: "leave the port off: verification uses DNS, not a web server" };
|
||||
const host = u.hostname; // WHATWG URL gives punycode for IDN
|
||||
if (host.endsWith(".onion")) return { ok: false, error: "an onion address cannot be verified by DNS; this field is for a clearnet domain you own" };
|
||||
if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("[")) {
|
||||
return { ok: false, error: "use a domain name, not an IP address" };
|
||||
}
|
||||
if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) {
|
||||
return { ok: false, error: "that is not a valid domain name" };
|
||||
}
|
||||
if (host.split(".").length < 2) return { ok: false, error: "include the full domain, for example example.com" };
|
||||
return { ok: true, domain: host, punycode: /[^\x00-\x7F]/.test(String(input)) || host.includes("xn--") };
|
||||
}
|
||||
|
||||
export const txtName = (domain: string): string => `${TXT_PREFIX}.${domain}`;
|
||||
/** The Host/Name field in a DNS panel is relative to the zone, so most control
|
||||
* panels want just this label. Handing over the fully-qualified name instead is
|
||||
* the classic way to end up with _dojobay.example.com.example.com. */
|
||||
export const txtHost = (): string => TXT_PREFIX;
|
||||
export const txtValue = (paymentCode: string): string => `${CLAIM_VERSION} pm=${paymentCode}`;
|
||||
export const signingText = (domain: string, paymentCode: string): string => claimText(`https://${domain}`, paymentCode);
|
||||
|
||||
// Does a TXT record claim this payment code? Tolerant of extra whitespace and
|
||||
// of the record being wrapped in quotes by a DNS UI, strict about the code.
|
||||
export function txtMatches(record: unknown, paymentCode: string): boolean {
|
||||
const r = String(record || "").trim().replace(/^"|"$/g, "").replace(/\s+/g, " ");
|
||||
if (!r.startsWith(CLAIM_VERSION)) return false;
|
||||
const m = r.match(/\bpm=(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
return !!m && m[1] === paymentCode;
|
||||
}
|
||||
|
||||
// Full verification: the signature first (cheap, local, and the operator's most
|
||||
// likely mistake), then DNS (slow, over Tor).
|
||||
export async function verifyClaim(
|
||||
{ domain, paymentCode, signed }: { domain: string; paymentCode: string; signed: string },
|
||||
cfg: LookupCfg = {},
|
||||
): Promise<ClaimVerification> {
|
||||
const sig = verifySignedUrlClaim({ signed, expectedUrl: `https://${domain}`, paymentCode });
|
||||
if (!sig.ok) return { ok: false, stage: "signature", error: sig.error };
|
||||
const dns = await txtRecordAgreed(txtName(domain), (r) => txtMatches(r, paymentCode), cfg);
|
||||
if (!dns.ok) {
|
||||
return { ok: false, stage: "dns", inconclusive: !!dns.inconclusive, error: dns.error,
|
||||
hint: dns.inconclusive ? null : `publish a TXT record at ${txtName(domain)} containing: ${txtValue(paymentCode)}` };
|
||||
}
|
||||
return { ok: true, agreed: dns.agreed, answered: dns.answered };
|
||||
}
|
||||
|
||||
// DNS-only re-check for the periodic sweep: the signature is immutable once
|
||||
// accepted, so there is nothing to re-verify locally.
|
||||
export async function recheckClaim(claim: DomainClaim, cfg: LookupCfg = {}): Promise<TxtAgreement> {
|
||||
return txtRecordAgreed(txtName(claim.domain), (r) => txtMatches(r, claim.paymentCode), cfg);
|
||||
}
|
||||
|
||||
// Fold a re-check result into a claim, applying the grace period. Pure, so the
|
||||
// policy is testable without any network.
|
||||
export function applyRecheck(claim: DomainClaim, result: TxtAgreement, now: number = Date.now()): DomainClaim {
|
||||
const next: DomainClaim = { ...claim, last_check: new Date(now).toISOString() };
|
||||
if (result.inconclusive) {
|
||||
// Could not tell. Change nothing except the timestamp, and say so.
|
||||
next.last_result = "inconclusive: " + (result.error || "no detail");
|
||||
return next;
|
||||
}
|
||||
if (result.ok) {
|
||||
next.verified = true;
|
||||
next.verified_at = next.verified_at || new Date(now).toISOString();
|
||||
next.fail_since = null;
|
||||
next.last_result = "ok";
|
||||
return next;
|
||||
}
|
||||
next.last_result = result.error || "no matching TXT record";
|
||||
next.fail_since = claim.fail_since || new Date(now).toISOString();
|
||||
const failingMs = now - Date.parse(next.fail_since);
|
||||
if (failingMs >= GRACE_DAYS * 86400 * 1000) next.verified = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
export const isDue = (claim: DomainClaim, now: number = Date.now()): boolean => {
|
||||
if (!claim.last_check) return true;
|
||||
const since = now - Date.parse(claim.last_check);
|
||||
return since >= (claim.verified ? RECHECK_MS : PENDING_RECHECK_MS);
|
||||
};
|
||||
|
||||
// A URL is publishable as a card link only if it sits on the operator's verified
|
||||
// domain (the domain itself or a subdomain of it). This is what stops a card
|
||||
// carrying an unverifiable social profile while keeping "link to my own site".
|
||||
export function urlOnDomain(url: unknown, domain: string | null | undefined): boolean {
|
||||
if (!url || !domain) return false;
|
||||
let u: URL;
|
||||
try { u = new URL(String(url)); } catch { return false; }
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") return false;
|
||||
const h = u.hostname.toLowerCase();
|
||||
return h === domain || h.endsWith("." + domain);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — restore payload.pairing.version to the value that was signed.
|
||||
//
|
||||
// Some records have a stored pairing payload whose `version` was updated after
|
||||
// the operator signed it (a Dojo upgrade, typically), so the published payload
|
||||
// no longer matches the signature that attests to it. The version is purely
|
||||
// informational and the live value is read from the node's X-Dojo-Version
|
||||
// header on every probe, so the right correction is to put the payload back to
|
||||
// what was signed and let the next signed submission move both together.
|
||||
//
|
||||
// STRICTLY LIMITED: this only ever writes payload.pairing.version, and only on
|
||||
// records where the signed block and the stored payload are otherwise
|
||||
// identical (key order and whitespace ignored). Anything else is reported and
|
||||
// left alone.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node fix-payload-version.mjs # dry run, changes nothing
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node fix-payload-version.mjs --apply # writes, after a backup
|
||||
// sudo systemctl start dojobay-server.service
|
||||
//
|
||||
// The stop/start matters: server/store.ts keeps the store in memory and is
|
||||
// designed as a single writer, so editing store.json underneath a running
|
||||
// server would be overwritten by its next session or nonce write. --apply
|
||||
// refuses to run while the service is active unless you pass --force.
|
||||
// =============================================================================
|
||||
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";
|
||||
import { parseSignedBlock } from "./crypto.ts";
|
||||
|
||||
const APPLY = process.argv.includes("--apply");
|
||||
const FORCE = process.argv.includes("--force");
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
const stable = (v) => {
|
||||
if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]";
|
||||
if (v && typeof v === "object") {
|
||||
return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + stable(v[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v) ?? "null";
|
||||
};
|
||||
// Everything except pairing.version, so we can prove that is the only difference.
|
||||
const withoutVersion = (payload) => {
|
||||
const p = structuredClone(payload || {});
|
||||
if (p.pairing && typeof p.pairing === "object") delete p.pairing.version;
|
||||
return { pairing: p.pairing, explorer: p.explorer };
|
||||
};
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } catch (e) { 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 fix-payload-version.mjs --apply\n" +
|
||||
" sudo systemctl start dojobay-server.service\n" +
|
||||
"(--force overrides this check, but do not use it on a live instance.)");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await readFile(FILE, "utf8");
|
||||
const doc = JSON.parse(raw);
|
||||
const recs = Object.values(doc.submissions || {}).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const planned = [];
|
||||
const skipped = [];
|
||||
for (const rec of recs) {
|
||||
if (!rec.signed) continue;
|
||||
const p = parseSignedBlock(rec.signed);
|
||||
if (!p) { skipped.push([rec.id, "signed block does not parse"]); continue; }
|
||||
let signedObj;
|
||||
try { signedObj = JSON.parse(p.pairingText); } catch { skipped.push([rec.id, "signed text is not a bare pairing JSON (extra content around it)"]); continue; }
|
||||
const sv = signedObj?.pairing?.version ?? null;
|
||||
const cv = rec.payload?.pairing?.version ?? null;
|
||||
if (sv === cv) continue; // nothing to do
|
||||
if (stable(withoutVersion(signedObj)) !== stable(withoutVersion(rec.payload))) {
|
||||
skipped.push([rec.id, `differs beyond the version (signed ${JSON.stringify(sv)} vs stored ${JSON.stringify(cv)}), left alone`]);
|
||||
continue;
|
||||
}
|
||||
planned.push({ rec, from: cv, to: sv });
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Records with a signed block: ${recs.filter((r) => r.signed).length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Version-only differences (${planned.length}) — payload.pairing.version will be set back to the signed value:`);
|
||||
for (const { rec, from, to } of planned) console.log(` ${rec.id}: ${JSON.stringify(from)} -> ${JSON.stringify(to)}`);
|
||||
console.log("");
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`Not touched (${skipped.length}):`);
|
||||
for (const [id, why] of skipped) console.log(` ${id}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to change."); process.exit(0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (with the 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);
|
||||
for (const { rec, to } of planned) doc.submissions[rec.id].payload.pairing.version = to;
|
||||
// A temporary name no other writer can take; see build-public.ts. One write per
|
||||
// run, so the pid alone distinguishes it.
|
||||
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} change(s).`);
|
||||
console.log("Start the service again, then re-run audit-signed.mjs. The published\n" +
|
||||
"dojos.json picks the corrected payload up on the next updater cycle.");
|
||||
@@ -0,0 +1,25 @@
|
||||
// Launcher for the backend, which lives in index.ts.
|
||||
//
|
||||
// This file stays plain JavaScript on purpose, for three reasons:
|
||||
//
|
||||
// 1. It can be parsed by ANY Node version, so an operator on an older runtime
|
||||
// gets the message below instead of a syntax error from a .ts file they
|
||||
// cannot execute. The check must run before the import, hence the dynamic
|
||||
// import rather than a static one.
|
||||
// 2. systemd, `npm start` and the README all name index.mjs, so nothing about
|
||||
// deployment changes.
|
||||
// 3. self-update.mjs sanity-checks that an update archive contains
|
||||
// server/index.mjs before accepting it. Renaming this file outright would
|
||||
// make every legitimate update look malformed.
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isNaN(major) || major < 24) {
|
||||
console.error(
|
||||
`The Dojo Bay backend needs Node 24 or newer (found ${process.versions.node}).\n` +
|
||||
"It runs TypeScript directly, which relies on type stripping added in Node 24,\n" +
|
||||
"and its BIP47 libraries require it too. Upgrade Node, then restart the service.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import("./index.ts");
|
||||
export const server = mod.server;
|
||||
export const routes = mod.routes;
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@dojo-tools/auth47": "2.0.0",
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bip39": "3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
|
||||
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/auth47": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/auth47/-/auth47-2.0.0.tgz",
|
||||
"integrity": "sha512-uoSG3MGy0TLanBftq49v9MdcFgy4xzCVU2iUUWEpQreFvfeVuxQbaTMGi5g7R0UafLiVVMIFf1rcF6JvvkxHbQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/bip47": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/bip47/-/bip47-2.0.0.tgz",
|
||||
"integrity": "sha512-0B5nlP/71ArOY8yPOjuJacPPiHPeyZOlI8618VJcfiibZQGugEFV3HIG5Doxo6sSSwU5NrX/96OF6KG4pmzsOA==",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"bip32": "5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/bitcoinjs-message": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/bitcoinjs-message/-/bitcoinjs-message-4.0.0.tgz",
|
||||
"integrity": "sha512-bVK5tsrORzZ3aMgI5kn9YcfAqJzdiObFL/3Pxg8+ouTsXdyDOrBoITpCniemLxMUCanXUWou9LWg7Th5PzFLOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bip32": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz",
|
||||
"integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@scure/base": "^1.1.1",
|
||||
"uint8array-tools": "^0.0.8",
|
||||
"valibot": "^1.2.0",
|
||||
"wif": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bip32/node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bip39": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
|
||||
"integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip39/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
|
||||
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bs58": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
||||
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wif": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
|
||||
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs58check": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Auth47-gated self-service submission API for The Dojo Bay.",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"build-public": "node build-public.mjs",
|
||||
"test": "node selftest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dojo-tools/auth47": "2.0.0",
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bip39": "3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// PayNym.rs lookup. paynym.rs runs the same API the historical Samourai server
|
||||
// exposed, and offers both a clearnet host and a Tor onion. We prefer the onion
|
||||
// (the box already has a SOCKS proxy for the connection gate, and it keeps the
|
||||
// lookup inside Tor), falling back to clearnet.
|
||||
//
|
||||
// The call is POST {base}/api/v1/nym body {"nym": "<payment code>"} and the
|
||||
// response carries the registered nym label. This resolution is ALWAYS
|
||||
// best-effort: any failure returns null and callers must carry on, because a
|
||||
// paynym.rs outage must never block a submission or an approval.
|
||||
import { socks5Connect, PROBE_CFG } from "./probe.mjs";
|
||||
|
||||
// Override via env if the onion address changes.
|
||||
const PAYNYM_ONION = process.env.PAYNYM_ONION
|
||||
|| "http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion";
|
||||
const PAYNYM_CLEARNET = process.env.PAYNYM_CLEARNET || "https://paynym.rs";
|
||||
|
||||
// Pull the human label out of whatever shape the API returns. The legacy API
|
||||
// nests it under codes[].claimed / nymName; we probe a few known keys so a
|
||||
// minor schema change degrades to "not found" rather than a wrong value.
|
||||
function extractNym(obj) {
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const direct = obj.nymName || obj.nym_name || obj.nym;
|
||||
if (typeof direct === "string" && direct.length) return direct;
|
||||
if (Array.isArray(obj.codes) && obj.codes[0] && typeof obj.codes[0].claimed === "string") return obj.codes[0].claimed;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimal HTTP POST over a SOCKS5 stream (onion), reading the JSON body.
|
||||
function postOverTor(onionUrl, path, jsonBody, timeoutMs) {
|
||||
return new Promise(async (resolve) => {
|
||||
let socket;
|
||||
try {
|
||||
const u = new URL(onionUrl);
|
||||
socket = await socks5Connect(PROBE_CFG.proxyHost, PROBE_CFG.proxyPort, u.hostname, +(u.port || 80), timeoutMs);
|
||||
} catch { return resolve(null); }
|
||||
const body = Buffer.from(JSON.stringify(jsonBody), "utf8");
|
||||
const host = new URL(onionUrl).hostname;
|
||||
const req =
|
||||
`POST ${path} HTTP/1.0\r\nHost: ${host}\r\nContent-Type: application/json\r\n` +
|
||||
`Content-Length: ${body.length}\r\nConnection: close\r\n\r\n`;
|
||||
let buf = "";
|
||||
const done = (v) => { try { socket.destroy(); } catch {} resolve(v); };
|
||||
const timer = setTimeout(() => done(null), timeoutMs);
|
||||
socket.on("data", (d) => { buf += d.toString("utf8"); });
|
||||
socket.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
const i = buf.indexOf("\r\n\r\n");
|
||||
if (i < 0) return resolve(null);
|
||||
try { resolve(JSON.parse(buf.slice(i + 4))); } catch { resolve(null); }
|
||||
});
|
||||
socket.on("error", () => done(null));
|
||||
socket.write(req + body.toString("utf8"));
|
||||
});
|
||||
}
|
||||
|
||||
async function postClearnet(base, paymentCode, timeoutMs) {
|
||||
if (typeof fetch !== "function") return null;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
const r = await fetch(base + "/api/v1/nym", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nym: paymentCode }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Fetch the raw nym document (codes[], nymName, ...) for a handle or payment
|
||||
// code, Tor first. Returns the parsed object or null. Never throws.
|
||||
export async function fetchNymInfo(nymOrCode, { timeoutMs = 20000, preferTor = true } = {}) {
|
||||
if (!nymOrCode) return null;
|
||||
let obj = null;
|
||||
if (preferTor) obj = await postOverTor(PAYNYM_ONION, "/api/v1/nym", { nym: nymOrCode }, timeoutMs);
|
||||
if (!obj) obj = await postClearnet(PAYNYM_CLEARNET, nymOrCode, timeoutMs);
|
||||
return obj && typeof obj === "object" ? obj : null;
|
||||
}
|
||||
|
||||
// Every BIP47 code variant registered for a PayNym (segwit + legacy), because
|
||||
// the wallet may sign Auth47 with either. [] when unresolvable.
|
||||
export async function fetchNymCodes(nymOrCode, opts) {
|
||||
const info = await fetchNymInfo(nymOrCode, opts);
|
||||
return Array.isArray(info?.codes) ? info.codes.filter((c) => c && typeof c.code === "string") : [];
|
||||
}
|
||||
|
||||
// Resolve a payment code to its registered PayNym label, or null. Never throws.
|
||||
export async function resolvePayNym(paymentCode, opts) {
|
||||
const name = extractNym(await fetchNymInfo(paymentCode, opts));
|
||||
if (!name) return null;
|
||||
return name.startsWith("+") ? name : "+" + name;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// On-demand Tor reachability check, reusing the exact probe from the updater so
|
||||
// the self-service "connection gate" and the cron checker agree.
|
||||
export { probe, socks5Connect } from "../scripts/update.mjs";
|
||||
|
||||
export const PROBE_CFG = {
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
timeoutMs: +(process.env.TIMEOUT_MS || 30000),
|
||||
connectOnly: process.env.CONNECT_ONLY === "1",
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — remove a listing and its reliability history.
|
||||
//
|
||||
// Deleting a record through the API leaves its history behind: unlisting stamps
|
||||
// it `retired` and keeps it for HISTORY_GRACE_DAYS so that a node relisted
|
||||
// within the window resurrects its uptime intact. That is right for a node
|
||||
// coming back, and wrong for one being removed deliberately. This removes both,
|
||||
// now.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node remove-listing.ts <record-id> # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node remove-listing.ts --apply <record-id>
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node build-public.mjs
|
||||
//
|
||||
// As with the other write tools, --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. Both files are backed up first.
|
||||
// =============================================================================
|
||||
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";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const IDS = argv.filter((a) => !a.startsWith("--"));
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORE_DIR = process.env.SERVER_DATA_DIR || path.join(HERE, "data");
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
|
||||
const STORE = path.join(STORE_DIR, "store.json");
|
||||
const HISTORY = ["history.json", "history-daily.json"].map((f) => path.join(PUBLIC_DIR, f));
|
||||
|
||||
if (!IDS.length) {
|
||||
console.error("Usage: node remove-listing.ts [--apply] <record-id>…\n" +
|
||||
"Record ids are shown by audit-signed.mjs, for example mainnet-kilombino.");
|
||||
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 remove-listing.ts --apply <record-id>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const readJSON = async (p: string, fallback: any) => {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); } catch { return fallback; }
|
||||
};
|
||||
// A temporary name no other writer can take; see build-public.ts. The counter
|
||||
// matters as well as the pid, because one run rewrites both the store and the
|
||||
// seed in quick succession.
|
||||
let tmpSeq = 0;
|
||||
const writeAtomic = async (p: string, doc: any) => {
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
};
|
||||
|
||||
const store = await readJSON(STORE, { submissions: {} });
|
||||
const found: StoreRecord[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of IDS) {
|
||||
const rec = store.submissions?.[id];
|
||||
if (rec) found.push(rec); else missing.push(id);
|
||||
}
|
||||
|
||||
console.log(`Store: ${STORE}`);
|
||||
console.log(`History: ${PUBLIC_DIR}\n`);
|
||||
|
||||
for (const rec of found) {
|
||||
const codes = (rec.paymentCodes || []).length;
|
||||
console.log(` ${rec.id} (${rec.status})`);
|
||||
console.log(` name ${rec.name || "(none)"}`);
|
||||
console.log(` onion ${rec.payload?.pairing?.url || "(none)"}`);
|
||||
console.log(` codes ${codes || "NONE — this listing has no owner"}`);
|
||||
}
|
||||
for (const id of missing) console.log(` ${id}: not in the store`);
|
||||
console.log("");
|
||||
|
||||
let histCounts: Record<string, number> = {};
|
||||
for (const f of HISTORY) {
|
||||
const doc = await readJSON(f, { nodes: {} });
|
||||
histCounts[path.basename(f)] = IDS.filter((id) => doc.nodes && doc.nodes[id]).length;
|
||||
}
|
||||
console.log("History entries to remove: " +
|
||||
Object.entries(histCounts).map(([f, n]) => `${f}: ${n}`).join(", ") + "\n");
|
||||
|
||||
if (!found.length && !Object.values(histCounts).some(Boolean)) {
|
||||
console.log("Nothing to remove."); process.exit(missing.length ? 1 : 0);
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to remove.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
await copyFile(STORE, `${STORE}.bak-${stamp}`);
|
||||
for (const rec of found) delete store.submissions[rec.id];
|
||||
await writeAtomic(STORE, store);
|
||||
console.log(`Backup written: ${STORE}.bak-${stamp}`);
|
||||
|
||||
for (const f of HISTORY) {
|
||||
const doc = await readJSON(f, null);
|
||||
if (!doc || !doc.nodes) continue;
|
||||
let touched = false;
|
||||
for (const id of IDS) if (doc.nodes[id]) { delete doc.nodes[id]; touched = true; }
|
||||
if (!touched) continue;
|
||||
await copyFile(f, `${f}.bak-${stamp}`);
|
||||
await writeAtomic(f, doc);
|
||||
console.log(`Purged history from ${path.basename(f)} (backup alongside).`);
|
||||
}
|
||||
|
||||
console.log(`\nRemoved ${found.length} listing(s). Start the service, then run\n` +
|
||||
"build-public.mjs to republish, and audit-signed.mjs to confirm the result.");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
// Tiny JSON-file store for the backend. Single-writer (one server process),
|
||||
// atomic writes, no external database. Holds submissions, live sessions and
|
||||
// outstanding Auth47 nonces.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { StoreRecord, DomainClaim } from "../types.js";
|
||||
|
||||
/** A short-lived, single-use Auth47 nonce. */
|
||||
export interface Nonce { expires: number; [k: string]: unknown }
|
||||
/** A signed-in operator's session, keyed by a random cookie id. */
|
||||
export interface Session { paymentCode: string; expires: number; [k: string]: unknown }
|
||||
|
||||
interface StoreShape {
|
||||
submissions: Record<string, StoreRecord>;
|
||||
sessions: Record<string, Session>;
|
||||
nonces: Record<string, Nonce>;
|
||||
domains: Record<string, DomainClaim>;
|
||||
}
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
const EMPTY: StoreShape = { submissions: {}, sessions: {}, nonces: {}, domains: {} };
|
||||
let cache: StoreShape | null = null;
|
||||
|
||||
// Whether a record carries a signed pairing block at all. A shape check, not a
|
||||
// verification: the submit gate decided whether the block verifies against the
|
||||
// operator's own payment code, and re-deriving that at every read would mean
|
||||
// the store and the rebuild silently dropping listings over a cryptographic
|
||||
// judgement made elsewhere. This asks only what a caller is entitled to ask
|
||||
// here, which is whether there is anything for a visitor to check.
|
||||
// server/audit-signed.mjs is the tool that re-runs the real verification over
|
||||
// the whole store. It lives in this file rather than beside the verifier so
|
||||
// that store.ts stays on node builtins alone: remove-listing.ts and the
|
||||
// migration scripts import the store, and should not have to pull in secp256k1
|
||||
// to ask a question about a string.
|
||||
export function hasSignedBlock(rec: { signed?: string | null } | null | undefined): boolean {
|
||||
const signed = typeof rec?.signed === "string" ? rec.signed.trim() : "";
|
||||
return signed.includes("BEGIN BITCOIN SIGNED MESSAGE") && signed.includes("BEGIN BITCOIN SIGNATURE");
|
||||
}
|
||||
|
||||
// A submission's ownership is a paymentCodes ARRAY, because one PayNym often
|
||||
// carries two BIP47 codes (segwit and legacy variants) and the wallet may sign
|
||||
// Auth47 with either. Records written before this schema carried a scalar
|
||||
// paymentCode; normalise those on read so old store files keep working.
|
||||
function normaliseSubmission<T>(rec: T): T {
|
||||
if (!rec || typeof rec !== "object") return rec;
|
||||
const r = rec as { paymentCodes?: unknown; paymentCode?: string };
|
||||
if (!Array.isArray(r.paymentCodes)) {
|
||||
r.paymentCodes = r.paymentCode ? [r.paymentCode] : [];
|
||||
}
|
||||
r.paymentCodes = [...new Set((r.paymentCodes as unknown[]).filter((c): c is string => typeof c === "string" && !!c))];
|
||||
delete r.paymentCode;
|
||||
return rec;
|
||||
}
|
||||
|
||||
async function load(): Promise<StoreShape> {
|
||||
if (cache) return cache;
|
||||
await mkdir(DIR, { recursive: true });
|
||||
try {
|
||||
cache = { ...EMPTY, ...JSON.parse(await readFile(FILE, "utf8")) };
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
||||
cache = structuredClone(EMPTY);
|
||||
}
|
||||
for (const rec of Object.values(cache.submissions)) normaliseSubmission(rec);
|
||||
return cache;
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take; see build-public.ts. The store has
|
||||
// a single writer by design, but the backend and a maintenance script can both
|
||||
// be pointed at it, and that is precisely when a shared temporary name bites.
|
||||
let tmpSeq = 0;
|
||||
async function persist() {
|
||||
const tmp = `${FILE}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(cache, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
}
|
||||
|
||||
export const store = {
|
||||
async get() { return load(); },
|
||||
async save() { await persist(); },
|
||||
|
||||
// --- nonces (single-use, short lived) ---
|
||||
async putNonce(nonce: string, data: Nonce) { (await load()).nonces[nonce] = data; await persist(); },
|
||||
async takeNonce(nonce: string): Promise<Nonce | null> {
|
||||
const s = await load();
|
||||
const n = s.nonces[nonce];
|
||||
if (n) { delete s.nonces[nonce]; await persist(); }
|
||||
return n || null;
|
||||
},
|
||||
async gcNonces(now: number = Date.now()) {
|
||||
const s = await load();
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(s.nonces)) {
|
||||
if (!v || v.expires < now) { delete s.nonces[k]; changed = true; }
|
||||
}
|
||||
if (changed) await persist();
|
||||
},
|
||||
|
||||
// --- sessions ---
|
||||
async putSession(data: Session): Promise<string> {
|
||||
const s = await load();
|
||||
const id = randomBytes(32).toString("hex");
|
||||
s.sessions[id] = data;
|
||||
await persist();
|
||||
return id;
|
||||
},
|
||||
async getSession(id: string | null | undefined): Promise<Session | null> {
|
||||
if (!id) return null;
|
||||
const s = await load();
|
||||
const sess = s.sessions[id];
|
||||
if (!sess) return null;
|
||||
if (sess.expires < Date.now()) { delete s.sessions[id]; await persist(); return null; }
|
||||
return sess;
|
||||
},
|
||||
async dropSession(id: string) {
|
||||
const s = await load();
|
||||
if (s.sessions[id]) { delete s.sessions[id]; await persist(); }
|
||||
},
|
||||
|
||||
// --- submissions (keyed by network + name slug; owned by paymentCodes[]) ---
|
||||
async listSubmissions(): Promise<StoreRecord[]> { return Object.values((await load()).submissions); },
|
||||
async submissionsFor(paymentCode: string): Promise<StoreRecord[]> {
|
||||
return Object.values((await load()).submissions)
|
||||
.filter((r) => Array.isArray(r.paymentCodes) && r.paymentCodes.includes(paymentCode));
|
||||
},
|
||||
// Every record must carry at least one BIP47 payment code and a signed
|
||||
// pairing block. This is the single chokepoint through which every write to
|
||||
// the store passes, so enforcing both here is what makes an unowned or
|
||||
// unattested listing structurally impossible rather than merely discouraged:
|
||||
// the payment code is the identity the directory rests on, and the signature
|
||||
// is what lets a visitor check the pairing details against that identity
|
||||
// without trusting this site at all. A listing without one cannot be owned,
|
||||
// edited, verified or recognised by a visitor; a listing without the other
|
||||
// asks the visitor to take our word for an onion address and an API key,
|
||||
// which is the one thing this directory exists not to require. Historically a
|
||||
// few pre-Auth47 records existed without a code, and rather more predate the
|
||||
// signature gate; both doors are now closed.
|
||||
async putSubmission(rec: StoreRecord): Promise<StoreRecord> {
|
||||
const normalised = normaliseSubmission(rec);
|
||||
const codes = (normalised as StoreRecord).paymentCodes;
|
||||
// An emptiness guard, deliberately, not a validator: whether a code is a
|
||||
// real BIP47 payment code is settled at the gates that admit it — an Auth47
|
||||
// session proves possession, and the signature checks derive its
|
||||
// notification address. What must be impossible HERE is a listing with no
|
||||
// owner at all.
|
||||
if (!Array.isArray(codes) || !codes.some((c) => typeof c === "string" && /^PM\w{6,}/.test(c.trim()))) {
|
||||
throw new Error(`refusing to store ${rec?.id}: a listing must carry a BIP47 payment code`);
|
||||
}
|
||||
// The same kind of guard for the signature: a shape check, not a
|
||||
// verification. Whether the block verifies against the record's own code is
|
||||
// settled at the submit and pairing-edit gates, which have the session and
|
||||
// the canonical message to hand and can say precisely what is wrong. What
|
||||
// must be impossible HERE is a record whose pairing details nobody has
|
||||
// attested to, however it was assembled — by an admin action, an import, a
|
||||
// migration or a future endpoint that has not been written yet.
|
||||
if (!hasSignedBlock(normalised)) {
|
||||
throw new Error(`refusing to store ${rec?.id}: a listing must carry a signed pairing block. ` +
|
||||
`Ask the operator to sign their pairing payload, or remove the listing with server/remove-listing.ts.`);
|
||||
}
|
||||
const s = await load();
|
||||
s.submissions[rec.id] = normalised;
|
||||
await persist();
|
||||
return rec;
|
||||
},
|
||||
async getSubmission(id: string): Promise<StoreRecord | null> {
|
||||
const rec = (await load()).submissions[id] || null;
|
||||
return rec ? normaliseSubmission(rec) : null;
|
||||
},
|
||||
// Retention: a rejected submission is kept briefly so a maintainer can reverse
|
||||
// a mistake, then deleted. Nothing else ever removed one, so the store
|
||||
// accumulated the payment code, pairing payload and signature of every
|
||||
// operator ever turned down — including the apikey, which is a live
|
||||
// credential to their Dojo, not merely metadata. Returns the ids removed.
|
||||
async pruneRejected(days: number, now: number = Date.now()): Promise<string[]> {
|
||||
const s = await load();
|
||||
const cutoff = now - days * 86400 * 1000;
|
||||
const gone: string[] = [];
|
||||
for (const [id, rec] of Object.entries(s.submissions)) {
|
||||
if (rec?.status !== "rejected") continue;
|
||||
const stamp = Date.parse(rec.updated_at || rec.created_at || "");
|
||||
// A record with no usable timestamp is pruned rather than kept forever.
|
||||
if (Number.isFinite(stamp) && stamp > cutoff) continue;
|
||||
delete s.submissions[id];
|
||||
gone.push(id);
|
||||
}
|
||||
if (gone.length) await persist();
|
||||
return gone;
|
||||
},
|
||||
|
||||
async deleteSubmission(id: string) {
|
||||
const s = await load();
|
||||
if (s.submissions[id]) { delete s.submissions[id]; await persist(); }
|
||||
},
|
||||
|
||||
// --- verified operator domains (keyed by payment code) ---------------------
|
||||
// One claim per code. A record is kept even after it stops verifying, so
|
||||
// restoring the TXT record restores the badge without a fresh signature.
|
||||
async listDomains(): Promise<DomainClaim[]> { return Object.values((await load()).domains || {}); },
|
||||
async getDomain(paymentCode: string): Promise<DomainClaim | null> { return ((await load()).domains || {})[paymentCode] || null; },
|
||||
async putDomain(claim: DomainClaim): Promise<DomainClaim> {
|
||||
const s = await load();
|
||||
s.domains = s.domains || {};
|
||||
s.domains[claim.paymentCode] = claim;
|
||||
await persist();
|
||||
return claim;
|
||||
},
|
||||
async deleteDomain(paymentCode: string) {
|
||||
const s = await load();
|
||||
if (s.domains && s.domains[paymentCode]) { delete s.domains[paymentCode]; await persist(); }
|
||||
},
|
||||
// Every verified domain, as a payment code -> domain map, for the rebuild.
|
||||
async verifiedDomainMap(): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>();
|
||||
for (const c of Object.values((await load()).domains || {})) {
|
||||
if (c && c.verified && c.domain) out.set(c.paymentCode, c.domain);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
// How far behind is this instance? Compares the local data/version.json
|
||||
// commit against the GitHub repository, over Tor (TLS through the same SOCKS
|
||||
// tunnel the probes use), and reports commits behind plus releases published
|
||||
// since this instance was built. Consumed by GET /api/admin/updates for the
|
||||
// admin console's update line. Everything degrades gracefully: if GitHub is
|
||||
// unreachable over Tor, the endpoint says so rather than failing the panel.
|
||||
//
|
||||
// "Releases behind" resolves release tags to commits and compares identity, so
|
||||
// an instance running the exact commit of the newest release reports zero. The
|
||||
// earlier version counted releases published after the local build timestamp,
|
||||
// which always reported one behind: a tag is created after the commit it points
|
||||
// at has been built and deployed. When the running commit is not itself a
|
||||
// released tag, it falls back to that timestamp guess and flags it as such.
|
||||
import tls from "node:tls";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { socks5Connect } from "../scripts/update.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const GITHUB_REPO = process.env.GITHUB_REPO || "Dojobay/dojobay";
|
||||
const API_HOST = "api.github.com";
|
||||
|
||||
// The request line and headers, separated out so the Accept value is testable
|
||||
// without a network or a TLS mock. It is not a detail: a download used to ask
|
||||
// for `application/octet-stream`, and GitHub's archive route answers 415
|
||||
// Unsupported Media Type to that, so self-update never got past its first
|
||||
// request and no operator ever saw it work. Verified against the live endpoint:
|
||||
// octet-stream returns 415, while both `application/vnd.github+json` and `*/*`
|
||||
// return the 302 to codeload that this transport already follows.
|
||||
//
|
||||
// `*/*` rather than the JSON type, because a download genuinely will take
|
||||
// whatever the route serves and saying so is true; asking for JSON to obtain a
|
||||
// zip works only by convention and would be the next thing to break quietly.
|
||||
// The metadata calls keep the JSON type, which is what those routes serve and
|
||||
// what pins the API version.
|
||||
export function githubRequestHead(apiPath, host, { binary = false } = {}) {
|
||||
return `GET ${apiPath} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-update-check\r\n` +
|
||||
`Accept: ${binary ? "*/*" : "application/vnd.github+json"}\r\n` +
|
||||
`Accept-Encoding: identity\r\nConnection: close\r\n\r\n`;
|
||||
}
|
||||
|
||||
// One HTTPS GET over the Tor SOCKS proxy. Handles chunked replies, returns
|
||||
// both a text `body` and a raw `bodyBuf`, and follows GitHub's redirect from
|
||||
// api.github.com to codeload for zipball downloads (binary: true) up to a few
|
||||
// hops. Host is derived per hop so codeload.github.com is reached correctly.
|
||||
/**
|
||||
* @param {string} apiPath
|
||||
* @param {{ proxyHost?: string, proxyPort?: number, timeoutMs?: number,
|
||||
* binary?: boolean, _host?: string, _hops?: number }} [opts]
|
||||
*/
|
||||
export async function githubGet(apiPath, { proxyHost, proxyPort, timeoutMs = 30000, binary = false, _host = API_HOST, _hops = 0 } = {}) {
|
||||
const raw = await socks5Connect(proxyHost, proxyPort, _host, 443, timeoutMs);
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { socket.destroy(); reject(new Error("timeout")); }, timeoutMs);
|
||||
const socket = tls.connect({ socket: raw, servername: _host }, () => {
|
||||
socket.write(githubRequestHead(apiPath, _host, { binary }));
|
||||
});
|
||||
const chunks = [];
|
||||
socket.on("data", (d) => chunks.push(d));
|
||||
socket.on("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
socket.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const all = Buffer.concat(chunks);
|
||||
const headEnd = all.indexOf("\r\n\r\n");
|
||||
if (headEnd < 0) return reject(new Error("malformed reply"));
|
||||
const headText = all.subarray(0, headEnd).toString("latin1");
|
||||
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
if (!m) return reject(new Error("malformed reply"));
|
||||
const status = +m[1];
|
||||
const locM = headText.match(/\r\nlocation:\s*([^\r\n]+)/i);
|
||||
let bodyBuf = all.subarray(headEnd + 4);
|
||||
if (/transfer-encoding:\s*chunked/i.test(headText)) {
|
||||
const parts = []; let p = 0;
|
||||
for (;;) {
|
||||
const nl = bodyBuf.indexOf("\r\n", p);
|
||||
if (nl < 0) break;
|
||||
const size = parseInt(bodyBuf.subarray(p, nl).toString("latin1"), 16);
|
||||
if (!size) break;
|
||||
parts.push(bodyBuf.subarray(nl + 2, nl + 2 + size));
|
||||
p = nl + 2 + size + 2;
|
||||
}
|
||||
bodyBuf = Buffer.concat(parts);
|
||||
}
|
||||
resolve({ status, location: locM ? locM[1].trim() : null, bodyBuf });
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
if ([301, 302, 307, 308].includes(res.status) && res.location && _hops < 4) {
|
||||
const u = new URL(res.location);
|
||||
return githubGet(u.pathname + u.search, { proxyHost, proxyPort, timeoutMs, binary, _host: u.hostname, _hops: _hops + 1 });
|
||||
}
|
||||
return { status: res.status, body: res.bodyBuf.toString("utf8"), bodyBuf: res.bodyBuf };
|
||||
}
|
||||
|
||||
/** What to tell an operator when GitHub refuses a request.
|
||||
*
|
||||
* A bare "HTTP 403" reads as something broken here, and it is not: GitHub
|
||||
* allows sixty unauthenticated requests an hour PER IP ADDRESS, and a Tor exit
|
||||
* is one address shared with everyone else using it, so an instance can arrive
|
||||
* at an exit whose hour was already spent by strangers. Observed on a live
|
||||
* instance: limit 60, remaining 0, used 60, for an exit nobody here had made a
|
||||
* single request through.
|
||||
*
|
||||
* It clears by itself when the window rolls over, and often sooner on a new
|
||||
* circuit, since the limit follows the exit rather than the client. Saying so
|
||||
* is the difference between an operator waiting and an operator going looking
|
||||
* for a fault that does not exist. 429 is included because GitHub uses it for
|
||||
* secondary limits and means the same thing to a reader.
|
||||
*/
|
||||
export function githubRefusal(what, status) {
|
||||
if (status === 403 || status === 429) {
|
||||
// No call-site prefix. Which of the three requests hit the limit is of no
|
||||
// use to an operator, and "compare: GitHub is rate-limiting..." reads as
|
||||
// though "compare" were a thing that had gone wrong.
|
||||
return `GitHub is rate-limiting this Tor exit (HTTP ${status}). `
|
||||
+ "The limit is per exit address and shared with every other user of it, so it clears on its "
|
||||
+ "own within the hour, usually sooner on a new circuit. Updating from a peer .onion does not "
|
||||
+ "touch GitHub and works meanwhile.";
|
||||
}
|
||||
return `${what}: HTTP ${status}`;
|
||||
}
|
||||
|
||||
export async function checkUpdates({ repo = GITHUB_REPO, transport = githubGet, cfg = {} } = {}) {
|
||||
const verPath = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "version.json");
|
||||
const version = JSON.parse(await readFile(verPath, "utf8"));
|
||||
if (!version.commit || version.commit === "dev") throw new Error("local version.json has no deployed commit");
|
||||
|
||||
const cmp = await transport(`/repos/${repo}/compare/${encodeURIComponent(version.commit)}...main`, cfg);
|
||||
if (cmp.status !== 200) throw new Error(githubRefusal("compare", cmp.status));
|
||||
const compare = JSON.parse(cmp.body);
|
||||
|
||||
const rel = await transport(`/repos/${repo}/releases?per_page=30`, cfg);
|
||||
if (rel.status !== 200) throw new Error(githubRefusal("releases", rel.status));
|
||||
const releases = JSON.parse(rel.body);
|
||||
|
||||
// Which release are we actually running?
|
||||
//
|
||||
// This used to count releases published after the local build timestamp,
|
||||
// which is wrong in the ordinary case: a tag is created AFTER the commit it
|
||||
// points at has been built and deployed, so an instance running the exact
|
||||
// commit of the newest release always reported itself one release behind.
|
||||
//
|
||||
// Resolve each release's tag to a commit instead and compare identity. If our
|
||||
// commit IS a released tag, the number of releases published after it is the
|
||||
// honest answer (zero, when we are on the latest). Only when no tag matches do
|
||||
// we fall back to the timestamp approximation, and say so.
|
||||
let tagSha = new Map();
|
||||
let tagsError = null;
|
||||
try {
|
||||
const tg = await transport(`/repos/${repo}/tags?per_page=100`, cfg);
|
||||
if (tg.status === 200) {
|
||||
for (const t of JSON.parse(tg.body)) {
|
||||
if (t?.name && t?.commit?.sha) tagSha.set(t.name, String(t.commit.sha));
|
||||
}
|
||||
} else {
|
||||
tagsError = githubRefusal("tag lookup", tg.status);
|
||||
}
|
||||
} catch (e) {
|
||||
tagsError = "tag lookup: " + (e?.message || "failed");
|
||||
}
|
||||
|
||||
// version.json carries a short commit, the API a full sha; match either way.
|
||||
const sameCommit = (a, b) => {
|
||||
if (!a || !b) return false;
|
||||
const x = String(a).toLowerCase(), y = String(b).toLowerCase();
|
||||
return x.startsWith(y) || y.startsWith(x);
|
||||
};
|
||||
|
||||
const runningIndex = releases.findIndex((r) => sameCommit(tagSha.get(r.tag_name), version.commit));
|
||||
const approximate = runningIndex < 0;
|
||||
const builtAt = Date.parse(version.built || 0) || 0;
|
||||
|
||||
// Three states, and the third used to be reported as the second.
|
||||
//
|
||||
// matched our commit IS a released tag: the count is exact.
|
||||
// no match we are on an untagged commit mid-cycle: the timestamp count
|
||||
// is a fair approximation, because we are genuinely not on a
|
||||
// release.
|
||||
// no tag data we could not look tags up at all, usually because a shared
|
||||
// Tor exit hit GitHub's rate limit. The timestamp count is
|
||||
// then WORSE than saying nothing: a tag is always created
|
||||
// after its commit was built, so an instance running the very
|
||||
// newest release scores one behind. Report null instead.
|
||||
const releasesBehind = !approximate ? runningIndex
|
||||
: tagsError ? null
|
||||
: releases.filter((r) => Date.parse(r.published_at || 0) > builtAt).length;
|
||||
|
||||
return {
|
||||
commit: version.commit,
|
||||
built: version.built || null,
|
||||
commits_behind: compare.ahead_by ?? 0, // main is ahead of us by this many
|
||||
status: compare.status || "unknown", // identical | behind | ahead | diverged
|
||||
latest_release: releases[0] ? releases[0].tag_name : null,
|
||||
/** The release we are running, when our commit is exactly a released tag. */
|
||||
current_release: approximate ? null : releases[runningIndex].tag_name,
|
||||
releases_behind: releasesBehind,
|
||||
/** True when releases_behind is the timestamp guess rather than an identity
|
||||
* match, which happens when the running commit is not itself a released
|
||||
* tag (mid-cycle, or a local build). */
|
||||
releases_behind_approx: approximate,
|
||||
/** Why the release could not be identified, when it could not. */
|
||||
releases_note: tagsError,
|
||||
repo,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an update check may be answered from the cache, and what to tell the
|
||||
* operator if a forced check was refused.
|
||||
*
|
||||
* Three rules, and the third is the only interesting one. An ordinary request
|
||||
* takes the cache while it is fresh, because six hours is right for an
|
||||
* unattended check over Tor where GitHub rate limits shared exit nodes. A
|
||||
* forced request goes out. A forced request inside the floor is answered from
|
||||
* the cache with the wait attached rather than refused, because the operator
|
||||
* asked what the state is and the honest answer is the last one known plus how
|
||||
* stale it is.
|
||||
*
|
||||
* Pure, and separate from the route, so the floor can be tested without a
|
||||
* reachable GitHub: the route only fills its cache on success, so an
|
||||
* unreachable GitHub means the cached path is never taken and the floor never
|
||||
* runs. A rule that cannot be exercised is a rule nobody has checked.
|
||||
*/
|
||||
export function updateCacheDecision({ cachedAt = null, now = Date.now(), forced = false,
|
||||
forcedAt = 0, ttlMs = 6 * 3600 * 1000, floorMs = 60 * 1000 } = {}) {
|
||||
const fresh = cachedAt !== null && now - cachedAt < ttlMs;
|
||||
if (!fresh) return { serveCached: false, waitS: 0 };
|
||||
if (!forced) return { serveCached: true, waitS: 0 };
|
||||
const since = now - forcedAt;
|
||||
if (forcedAt && since < floorMs) {
|
||||
return { serveCached: true, waitS: Math.ceil((floorMs - since) / 1000) };
|
||||
}
|
||||
return { serveCached: false, waitS: 0 };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// The Dojo Bay — service worker.
|
||||
// Strategy:
|
||||
// * app shell (html, css, js, fonts, icons, content) is precached and served
|
||||
// cache-first, so the installed PWA opens instantly and works offline;
|
||||
// * live data (data/*.json) and the Markdown text (content/*.md) are fetched
|
||||
// network-first so a connected client always sees the latest snapshot,
|
||||
// falling back to cache when offline.
|
||||
// Bump CACHE when you ship new assets to retire the old cache.
|
||||
const CACHE = "dojobay-v2";
|
||||
|
||||
const SHELL = [
|
||||
"./",
|
||||
"index.html",
|
||||
"assets/css/styles.css",
|
||||
"assets/js/app.js",
|
||||
"assets/js/markdown.js",
|
||||
"assets/js/qrcode.js",
|
||||
"assets/fonts/archivo.woff2",
|
||||
"assets/fonts/hanken-grotesk.woff2",
|
||||
"assets/fonts/jetbrains-mono.woff2",
|
||||
"favicon.svg",
|
||||
"manifest.json",
|
||||
"assets/icons/192x192.png",
|
||||
"assets/icons/512x512.png",
|
||||
"content/about.md",
|
||||
"content/faq.md",
|
||||
"content/disclaimer.md",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE)
|
||||
.then((c) => Promise.allSettled(SHELL.map((u) => c.add(u))))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // ignore cross-origin
|
||||
|
||||
// Cache-first ONLY for large, rarely-changing static assets (fonts, icons,
|
||||
// images). Everything else (HTML, JS, CSS, JSON data, Markdown) is
|
||||
// network-first, so a deploy propagates on the next load and the cache is
|
||||
// only an offline fallback. This avoids stale code surviving a deploy.
|
||||
const cacheFirst = /\.(woff2|png|svg|ico)$/.test(url.pathname);
|
||||
|
||||
if (!cacheFirst) {
|
||||
e.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); return res; })
|
||||
.catch(() => caches.match(req))
|
||||
);
|
||||
} else {
|
||||
e.respondWith(
|
||||
caches.match(req).then((hit) =>
|
||||
hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
return res;
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
// =============================================================================
|
||||
// Shared shapes for the type-checked JavaScript pass.
|
||||
//
|
||||
// Nothing here is compiled or shipped: `npm run typecheck` reads it, Node never
|
||||
// sees it. It exists because the record shapes in this project have drifted
|
||||
// repeatedly — detected_version, indexer_url and operator_domain were each added
|
||||
// in a different commit, in three different places — and there was no single
|
||||
// statement of what a node record actually is. Referencing these from JSDoc
|
||||
// (`@type {import("../types.js").PublicNode}`) makes a drift a type error.
|
||||
// =============================================================================
|
||||
|
||||
/** A node as published in data/dojos.json and rendered on a card. */
|
||||
export interface PublicNode {
|
||||
id: string;
|
||||
network: "mainnet" | "testnet";
|
||||
name: string;
|
||||
status: "active" | "inactive";
|
||||
paynym: string | null;
|
||||
paymentCode: string | null;
|
||||
jurisdiction: string | null;
|
||||
country: string | null;
|
||||
hardware: string | null;
|
||||
/** Effective version: the live-probed reading, else the pairing payload's. */
|
||||
version: string | null;
|
||||
/** Read from the node's X-Dojo-Version response header by the updater. */
|
||||
detected_version: string | null;
|
||||
/** Read from the node's /support/services by the updater. */
|
||||
detected_indexer: string | null;
|
||||
/** Published Electrum endpoint: detected, else declared. Null renders N/A. */
|
||||
indexer_url: string | null;
|
||||
/** The operator's verified domain, if they have proved one. */
|
||||
operator_domain: string | null;
|
||||
/** Everything a reader needs to check that claim themselves, without
|
||||
* trusting this instance: the TXT record to look up, and the signed
|
||||
* statement to verify. All of it is already public. */
|
||||
operator_domain_proof: {
|
||||
domain: string;
|
||||
paymentCode: string;
|
||||
txt_name: string;
|
||||
txt_value: string;
|
||||
signed: string;
|
||||
verified_at: string | null;
|
||||
} | null;
|
||||
checked_at: string | null;
|
||||
block_height: number | null;
|
||||
payload: PairingPayload;
|
||||
signed: string | null;
|
||||
}
|
||||
|
||||
export interface PairingPayload {
|
||||
pairing: {
|
||||
type: string;
|
||||
version?: string;
|
||||
apikey?: string;
|
||||
url: string;
|
||||
};
|
||||
explorer?: { type?: string; url?: string };
|
||||
// A Dojo export may carry these; the gate stores neither, because the
|
||||
// signature covers pairing and explorer only. Present here so a parsed export
|
||||
// types cleanly, not because anything reads them. See build-public.ts on why
|
||||
// the Electrum endpoint is probed rather than declared.
|
||||
indexer?: { type?: string; url?: string };
|
||||
services?: Array<{ type?: string; kind?: string; url?: string }>;
|
||||
}
|
||||
|
||||
/** An operator's submission as held in the store (server/data/store.json). */
|
||||
export interface StoreRecord {
|
||||
id: string;
|
||||
network: "mainnet" | "testnet";
|
||||
name: string;
|
||||
/** Moderation state; ids are immutable so history survives a rename. */
|
||||
status: "pending" | "approved" | "rejected";
|
||||
/** A PayNym usually has two BIP47 variants; either may have signed. */
|
||||
paymentCodes: string[];
|
||||
payload: PairingPayload;
|
||||
// Everything below is genuinely optional: records written by different paths
|
||||
// (submission, migration, bootstrap import) carry different subsets, and an
|
||||
// absent field and an explicit null both occur in the live store.
|
||||
/** Removed from the UI and never published. Older records may still carry it. */
|
||||
name_url?: string | null;
|
||||
paynym?: string | null;
|
||||
jurisdiction?: string | null;
|
||||
country?: string | null;
|
||||
hardware?: string | null;
|
||||
signed?: string | null;
|
||||
/** The probe result recorded when the submission was accepted. */
|
||||
last_probe?: ProbeResult;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/** Provenance when the record arrived via scripts/bootstrap-import. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** A verified operator domain, keyed by payment code. */
|
||||
export interface DomainClaim {
|
||||
paymentCode: string;
|
||||
domain: string;
|
||||
/** The wallet-signed statement; permanent, unlike the TXT record. */
|
||||
signed: string;
|
||||
verified: boolean;
|
||||
verified_at: string | null;
|
||||
last_check: string | null;
|
||||
last_result: string | null;
|
||||
/** Set when a re-check first fails; the grace period runs from here. */
|
||||
fail_since: string | null;
|
||||
created_at: string;
|
||||
revoked?: boolean;
|
||||
also_claimed_by?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport settings a probe cannot work without. Marked required deliberately:
|
||||
* omitting them is the bug that broke the installer's anchor check, where
|
||||
* net.connect was handed an undefined port.
|
||||
*/
|
||||
export interface ProbeCfg {
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
timeoutMs: number;
|
||||
/** Simultaneous Tor circuits. Only the cycle runner reads it; a single probe ignores it. */
|
||||
concurrency?: number;
|
||||
apikey?: string;
|
||||
network?: string;
|
||||
connectOnly?: boolean;
|
||||
dojoVersionHeader?: string;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
up: boolean;
|
||||
reason: string;
|
||||
ms: number;
|
||||
height?: number;
|
||||
blockTime?: number | null;
|
||||
detectedVersion?: string | null;
|
||||
detectedIndexer?: string | null;
|
||||
}
|
||||
|
||||
// Front-end globals: assets/js/app.js is a plain script, and these are provided
|
||||
// by the separate <script> tags for qrcode.js and markdown.js.
|
||||
declare global {
|
||||
const qrcode: (typeNumber: number, errorCorrectionLevel: string) => {
|
||||
addData(data: string): void;
|
||||
make(): void;
|
||||
getModuleCount(): number;
|
||||
isDark(row: number, col: number): boolean;
|
||||
};
|
||||
const markdown: { render(src: string): string };
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -619,6 +619,27 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"8188:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/dojobay/data:/app/data",
|
||||
"/var/lib/archipelago/dojobay/server-data:/app/server/data"
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,10 +64,10 @@ describe('appSessionConfig', () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
||||
// searxng's manifest publishes host port 8888 (apps/searxng/
|
||||
// manifest.yml) — assert against the manifest-generated value, which is
|
||||
// exactly what this test exists to protect.
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
|
||||
expect(resolveAppUrl('searxng')).toBe('http://192.0.2.10:8888')
|
||||
})
|
||||
|
||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"dojobay": 8188,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"cuprate": "Cuprate",
|
||||
"dojobay": "Dojo Bay",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
|
||||
@@ -362,6 +362,18 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.11-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.11-alpha</span>
|
||||
<span class="text-xs text-white/40">September 7, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Cuprate now syncs without burning a core for days.</strong> 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.</p>
|
||||
<p><strong>OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.</strong> 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.</p>
|
||||
<p><strong>Release publishing now checks the public Gitea download links before a manifest goes live.</strong> 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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.10-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -369,9 +381,9 @@ init()
|
||||
<span class="text-xs text-white/40">September 2, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Lightning sends work again.</strong> v1.8.9's move to LND 0.21's supported payment route shipped without a fee budget, and the API treats a missing one as zero allowed fees — so every wallet send failed "No route to the recipient" all day, on perfectly healthy channels. Payments now carry a proper fee budget and a test keeps it from ever regressing.</p>
|
||||
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> 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". The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't. Nodes without LND are untouched; an unreachable peer is retried gently.</p>
|
||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all, and payment routing failures even showed the receiving copy. The gate now reads your real channel list: 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", and only a genuinely channel-less node is sent to open one.</p>
|
||||
<p><strong>Lightning sends work again — v1.8.9's payment switch lost the fee budget.</strong> 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 <strong>zero allowed fees</strong>: 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.</p>
|
||||
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> 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.</p>
|
||||
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> 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 <em>receiving</em> 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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.9-alpha -->
|
||||
@@ -381,12 +393,13 @@ init()
|
||||
<span class="text-xs text-white/40">September 1, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the payment route the node's backend used — every send answered "Not Found". Payments now go through LND's supported v2 router route, slow multi-hop payments are still tracked to completion (never falsely declared failed), failures explain themselves in plain language, and a new test speaks the payment route directly at release-gate time so an image/backend mismatch like this can never ship silently again.</p>
|
||||
<p><strong>HTTP and HTTPS both work, and no longer break each other.</strong> The HTTPS listener used to pin a year-long browser policy (HSTS); once your browser had visited HTTPS, it silently rewrote the HTTP dashboard's calls to HTTPS — cross-origin, so everything showed "Failed to fetch"/CORS errors while the node was healthy. The pin is gone, the HTTPS listener now actively clears the stale policy browsers already cached (visit HTTPS once after this update to clear yours), and plain-HTTP access — which is deliberate on nodes whose self-signed certificate you haven't installed — keeps working exactly as before.</p>
|
||||
<p><strong>Apps open over HTTPS again, including Mempool, Bitcoin and IndeeHub.</strong> The launcher looked each app's port policy up in the signed catalog under the name you click, but the catalog lists that port under the app that owns it — so Mempool "did not connect", Bitcoin opened a plain-http tab, and Nostr sign-in on IndeeHub silently did nothing over HTTPS. Launches now follow the alias to the owning manifest, the catalog is loaded before the first app you open (not just in the App Store), and the Nostr bridge replies to the app frame's real origin instead of a stale recorded address.</p>
|
||||
<p><strong>Nginx Proxy Manager starts again.</strong> Its manifest was missing two things its image requires — the LetsEncrypt folder mount and the permission to bind low ports — leaving it in an endless restart loop on nodes that had it installed. Both are declared now; your existing certificates are untouched, and the fix arrives via the signed catalog without waiting for this release.</p>
|
||||
<p><strong>Portainer's first-run token is on the app page, not buried in "server logs".</strong> New Portainer versions hand the first admin a one-time setup token that was only printed in the container logs — on this box, that token now appears with your app's other credentials, with a copy button, and disappears once setup is done.</p>
|
||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all. The gate now looks at your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing needed from you", a far-side balance gets "you can receive but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p>
|
||||
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> 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.</p>
|
||||
<p><strong>The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.</strong> 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.</p>
|
||||
<p><strong>App frames open over HTTPS again — including the ones that "did not connect."</strong> 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.</p>
|
||||
<p><strong>Signing in to IndeeHub with Nostr works over HTTPS.</strong> 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.</p>
|
||||
<p><strong>Nginx Proxy Manager starts again.</strong> 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.</p>
|
||||
<p><strong>Portainer's first-run token is in the app page, not buried in "server logs."</strong> 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.</p>
|
||||
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> 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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.8-alpha -->
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6",
|
||||
"size_bytes": 64178056
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719",
|
||||
"size_bytes": 97736297
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.10-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
@@ -1319,7 +1319,7 @@
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"files": [
|
||||
{
|
||||
"content": "network = \"Mainnet\"\ntarget_max_memory = 3000000000\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
||||
"content": "network = \"Mainnet\"\nfast_sync = true\ntarget_max_memory = 8589934592\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
||||
}
|
||||
@@ -1350,8 +1350,8 @@
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"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.",
|
||||
"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 complete a browser login or hold a dashboard session cookie.",
|
||||
"container": 18089,
|
||||
"host": 18090,
|
||||
"protocol": "tcp"
|
||||
@@ -1360,7 +1360,7 @@
|
||||
"resources": {
|
||||
"cpu_limit": 0,
|
||||
"disk_limit": "300Gi",
|
||||
"memory_limit": "4Gi"
|
||||
"memory_limit": "10Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
@@ -5429,7 +5429,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "f982faeb9823062d9d39f6e4b38a171b4442cad0f35e74792ea161b5d77246ab9128044acbdc390ec23f921363af2d13bbba66c558b188d14d06a3f9a7f42406",
|
||||
"signature": "3e87496a7197177ea295eba416cd1ed9a2c41ddca3328a160b1db2c65d39ce813c2b1e63df1313680a8e48e0113e2bbe6118df01df4a777bd33b189e7ef69206",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-09-01"
|
||||
"updated": "2026-09-03"
|
||||
}
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6",
|
||||
"size_bytes": 64178056
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719",
|
||||
"size_bytes": 97736297
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.10-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-gitea-release-download-links.sh - verify Gitea's public release page
|
||||
# points users at the canonical HTTPS download URLs, not an internal ROOT_URL.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-gitea-release-download-links.sh VERSION ASSET_NAME...
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [ -z "$VERSION" ] || [ "$#" -lt 2 ]; then
|
||||
echo "usage: $0 VERSION ASSET_NAME..." >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
PUBLIC_BASE="${ARCHY_RELEASE_PUBLIC_BASE:-https://source.archipelago-foundation.org/lfg2025/archy}"
|
||||
page_url="$PUBLIC_BASE/releases/tag/v$VERSION"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 2; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
|
||||
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
curl -fsSL "$page_url" -o "$tmp"
|
||||
|
||||
python3 - "$tmp" "$PUBLIC_BASE" "$VERSION" "$page_url" "$@" <<'PY'
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import quote
|
||||
import sys
|
||||
|
||||
html_path, public_base, version, page_url, *assets = sys.argv[1:]
|
||||
with open(html_path, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
class LinkParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.hrefs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag.lower() != "a":
|
||||
return
|
||||
attrs = dict(attrs)
|
||||
href = attrs.get("href")
|
||||
if href:
|
||||
self.hrefs.append(href)
|
||||
|
||||
parser = LinkParser()
|
||||
parser.feed(html)
|
||||
hrefs = set(parser.hrefs)
|
||||
|
||||
bad_internal = sorted(
|
||||
h for h in hrefs
|
||||
if "/releases/download/" in h and h.startswith(("http://", "https://"))
|
||||
and not h.startswith(public_base + "/releases/download/")
|
||||
)
|
||||
|
||||
failures = []
|
||||
for asset in assets:
|
||||
expected = f"{public_base}/releases/download/v{quote(version)}/{quote(asset)}"
|
||||
if expected not in hrefs:
|
||||
matches = sorted(h for h in hrefs if h.endswith("/" + quote(asset)))
|
||||
if matches:
|
||||
failures.append(f"{asset}: expected {expected}, found {matches[0]}")
|
||||
else:
|
||||
failures.append(f"{asset}: expected {expected}, but no matching release-page link was found")
|
||||
|
||||
if bad_internal:
|
||||
failures.append("release page contains non-canonical download href(s):")
|
||||
failures.extend(f" {h}" for h in bad_internal[:10])
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: public release page has broken download links: {page_url}", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" {failure}", file=sys.stderr)
|
||||
print(
|
||||
"Fix the Gitea public URL/proxy configuration so release links are generated "
|
||||
"from the canonical HTTPS origin, then re-run the publish check.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OK: public release page download links use {public_base}")
|
||||
PY
|
||||
@@ -145,6 +145,11 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
|
||||
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|
||||
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
||||
|
||||
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||
"archipelago" \
|
||||
"archipelago-frontend-${VERSION}.tar.gz" \
|
||||
|| fail "release page download links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration before publishing."
|
||||
|
||||
# Assets are proven fetchable — only now may the manifest become live. First
|
||||
# incorporate concurrent work, then promote in a dedicated commit. Until the
|
||||
# final push succeeds the remote still serves the previous manifest.
|
||||
@@ -261,4 +266,10 @@ for b in bad:
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
|
||||
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||
"$ISO_NAME" \
|
||||
"$ISO_NAME.sha256" \
|
||||
"$ISO_NAME.sha256.json" \
|
||||
|| fail "ISO is uploaded but the release page links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration."
|
||||
|
||||
echo "ISO for v${VERSION} published and verified on $REMOTE."
|
||||
|
||||
Reference in New Issue
Block a user