Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f3415f0f | ||
|
|
c9c9ebe6d4 | ||
|
|
100993445b | ||
|
|
a4f80e7ec1 | ||
|
|
4ad34d3a0a | ||
|
|
c9bae926a5 | ||
|
|
cb3f7e8720 | ||
|
|
eb98ebb682 | ||
|
|
00682e6420 | ||
|
|
95cdc3daea | ||
|
|
1d05f2c27a | ||
|
|
b3f16d07a6 | ||
|
|
14d2b37e99 | ||
|
|
f5b255ee68 | ||
|
|
6e8d90fb5f | ||
|
|
66c4b0d375 | ||
|
|
0f74ebfbbe | ||
|
|
ee11863ada | ||
|
|
86052d9552 | ||
|
|
047ef98987 | ||
|
|
c681472e15 | ||
|
|
7c0ba14a00 | ||
|
|
eacd74e1db | ||
|
|
34b68001d1 | ||
|
|
0fac51b9c5 | ||
|
|
4f0d123f27 | ||
|
|
13b1329c21 | ||
|
|
c4aa72dccc | ||
|
|
d35474f774 | ||
|
|
a03f340bd1 | ||
|
|
caaa2e729e | ||
|
|
fbb3ada87d | ||
|
|
72e84439ee | ||
|
|
5081a4fe7d | ||
|
|
39727dacbc | ||
|
|
1e409007d4 | ||
|
|
8f144c3038 | ||
|
|
8258705df7 | ||
|
|
d13002e022 | ||
|
|
e625b29d9e | ||
|
|
c4ed9fb1fa | ||
|
|
2bc5e98edb | ||
|
|
c1e14f7c7a | ||
|
|
564ffe1c47 | ||
|
|
c34d6ef76f | ||
|
|
dac29baf97 | ||
|
|
ef8c3a76be | ||
|
|
dc7b598558 | ||
|
|
69f3a355c7 | ||
|
|
f5c0ba85cd | ||
|
|
973356df16 | ||
|
|
e5a0d95459 | ||
|
|
b9862c7643 | ||
|
|
6fe9c5f81b | ||
|
|
28454264ac | ||
|
|
84b04d1634 | ||
|
|
ce5c04d49d | ||
|
|
ce9fca1c38 | ||
|
|
e661f237f1 | ||
|
|
f9af30b08a | ||
|
|
87a5025341 | ||
|
|
2947277205 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 48
|
||||
versionName = "0.5.28"
|
||||
versionCode = 52
|
||||
versionName = "0.5.32"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -41,6 +41,17 @@ android {
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
// Local-only UAT builds install beside both the production companion
|
||||
// and its shared-key debug package. The ignored uat.keystore is made
|
||||
// on the validation box; it must never be used for a public artifact.
|
||||
create("uat") {
|
||||
storeFile = file("uat.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiduatkey"
|
||||
keyPassword = "android"
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -51,6 +62,13 @@ android {
|
||||
versionNameSuffix = "-debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
create("uat") {
|
||||
initWith(getByName("debug"))
|
||||
applicationIdSuffix = ".uat"
|
||||
versionNameSuffix = "-uat"
|
||||
signingConfig = signingConfigs.getByName("uat")
|
||||
matchingFallbacks += listOf("debug")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -118,8 +136,8 @@ tasks.register<Exec>("buildRustArm64") {
|
||||
|
||||
tasks.matching {
|
||||
it.name in listOf(
|
||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
)
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
|
||||
@@ -326,8 +326,9 @@ private object KioskWebView {
|
||||
private fun injectSafeAreaVars(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
||||
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
|
||||
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
|
||||
val sab = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom / density).toInt()
|
||||
// The insets listener fires on every pass (every IME show/hide); skip the
|
||||
// JS round-trip — and the Vue event it dispatches — when nothing changed.
|
||||
val stamp = "sa:$sat,$sab"
|
||||
@@ -377,7 +378,8 @@ private fun injectSafeAreaVars(view: WebView) {
|
||||
private fun injectTopInset(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
|
||||
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
|
||||
if (sat <= 0) return
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
@@ -991,6 +993,51 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML downloads are not handled by WebView.
|
||||
* Fetch only this connected node's public CA
|
||||
* over its always-available HTTP listener,
|
||||
* verify it is an actual CA certificate, then
|
||||
* hand it to Android's trusted system prompt.
|
||||
* No caller-controlled certificate bytes are
|
||||
* accepted by this bridge. */
|
||||
@android.webkit.JavascriptInterface
|
||||
fun installNodeCertificate() {
|
||||
scope.launch {
|
||||
try {
|
||||
val der = withContext(Dispatchers.IO) {
|
||||
val host = android.net.Uri.parse(serverUrl).host
|
||||
?: error("node URL has no host")
|
||||
val caUrl = java.net.URI(
|
||||
"http", null, host, 80, "/ca.crt", null, null,
|
||||
).toASCIIString()
|
||||
val request = okhttp3.Request.Builder().url(caUrl).build()
|
||||
okhttp3.OkHttpClient().newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("CA download failed")
|
||||
val bytes = response.body?.bytes() ?: error("empty CA")
|
||||
if (bytes.size > 64 * 1024) error("CA is too large")
|
||||
val cert = java.security.cert.CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(java.io.ByteArrayInputStream(bytes))
|
||||
as java.security.cert.X509Certificate
|
||||
if (cert.basicConstraints < 0) error("certificate is not a CA")
|
||||
cert.encoded
|
||||
}
|
||||
}
|
||||
val intent = android.security.KeyChain.createInstallIntent().apply {
|
||||
putExtra(android.security.KeyChain.EXTRA_CERTIFICATE, der)
|
||||
putExtra(
|
||||
android.security.KeyChain.EXTRA_NAME,
|
||||
"Archipelago node CA",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// Network failure, invalid CA, or no credential installer.
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
@@ -1523,6 +1570,11 @@ private fun InAppBrowser(
|
||||
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
// Once this WebView has painted an app, keep that surface visible during
|
||||
// same-app reloads/navigation. Covering every navigation with an opaque
|
||||
// Compose loader caused GitWorkshop to flash, and an IndeeHub auth reload
|
||||
// could remain covered when WebView omitted the final callback.
|
||||
var hasCommittedPage by remember { mutableStateOf(false) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// Main-frame load failure — the branded offline screen renders instead of
|
||||
@@ -1594,6 +1646,20 @@ private fun InAppBrowser(
|
||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||
// served over plain HTTP too — same dead-clipboard trap.
|
||||
addClipboardBridge()
|
||||
val appBrowserView = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun expectPageTransition() {
|
||||
appBrowserView.post {
|
||||
hasCommittedPage = false
|
||||
loading = true
|
||||
appBrowserView.invalidate()
|
||||
}
|
||||
}
|
||||
},
|
||||
"ArchipelagoSurface",
|
||||
)
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
@@ -1623,7 +1689,7 @@ private fun InAppBrowser(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
loading = !hasCommittedPage
|
||||
loadError = false
|
||||
view?.let {
|
||||
injectTopInset(it)
|
||||
@@ -1632,6 +1698,7 @@ private fun InAppBrowser(
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
hasCommittedPage = true
|
||||
loading = false
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
@@ -1641,6 +1708,14 @@ private fun InAppBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
// Fires when the new main-frame pixels are ready,
|
||||
// earlier and more reliably than onPageFinished
|
||||
// for service-worker-controlled SPAs.
|
||||
hasCommittedPage = true
|
||||
loading = false
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
@@ -1732,6 +1807,7 @@ private fun InAppBrowser(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
loadError = false
|
||||
hasCommittedPage = false
|
||||
loading = true
|
||||
browser?.reload()
|
||||
},
|
||||
|
||||
+68
-1
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.14-alpha (2026-09-13)
|
||||
|
||||
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
|
||||
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
|
||||
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
|
||||
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
|
||||
|
||||
## v1.8.13-alpha (2026-09-12)
|
||||
|
||||
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
|
||||
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
|
||||
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
|
||||
|
||||
## v1.8.12-alpha (2026-09-11)
|
||||
|
||||
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
|
||||
|
||||
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
|
||||
|
||||
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
|
||||
|
||||
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
|
||||
|
||||
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
|
||||
|
||||
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
|
||||
|
||||
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
|
||||
|
||||
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
|
||||
|
||||
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
|
||||
|
||||
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
|
||||
|
||||
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
|
||||
|
||||
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
|
||||
|
||||
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
|
||||
|
||||
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
|
||||
|
||||
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
|
||||
|
||||
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
|
||||
|
||||
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
|
||||
|
||||
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
|
||||
|
||||
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
|
||||
|
||||
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
|
||||
|
||||
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
|
||||
|
||||
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
|
||||
|
||||
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
|
||||
|
||||
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
|
||||
|
||||
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
|
||||
|
||||
## 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.
|
||||
@@ -52,7 +119,7 @@
|
||||
|
||||
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
|
||||
|
||||
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
|
||||
- **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
|
||||
|
||||
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
|
||||
|
||||
## Redistributed software (ISO and container registry)
|
||||
|
||||
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
|
||||
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
|
||||
no software license. Archipelago applies a documented integration patch and
|
||||
redistributes the resulting static application under an explicit owner risk
|
||||
acceptance dated 2026-09-11; this notice does not claim or grant upstream
|
||||
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
|
||||
|
||||
The Archipelago OS image is based on Debian and redistributes Debian packages
|
||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
||||
required for hardware support); per-package license texts are preserved at
|
||||
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
|
||||
release's notes. Container images offered through the app catalog and mirror
|
||||
registry remain under their upstream licenses (including GPL/AGPL software
|
||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
||||
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
|
||||
Jellyfin, MariaDB, and strfry); source links are provided in
|
||||
the app catalog. The modified mempool-frontend image is built from
|
||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
||||
|
||||
|
||||
@@ -11,7 +11,21 @@ Podman containers managed by the Rust backend.
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
[](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
|
||||
## Current release
|
||||
|
||||
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
|
||||
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
|
||||
The same source is mirrored through ngit for Nostr-native cloning and
|
||||
contribution:
|
||||
|
||||
```
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
```
|
||||
|
||||
Clone with ngit, or use the Gitea mirror when you need a conventional Git
|
||||
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## What is here
|
||||
|
||||
|
||||
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
|
||||
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
|
||||
For new apps, include `containerConfig` so the backend knows how to create the container.
|
||||
|
||||
## Storefront layout
|
||||
|
||||
Discovery merchandising is app-registry data, not node-OS layout. The optional
|
||||
top-level `storefront` block defines the ordered Popular Apps rows and the
|
||||
promotional banners placed before the remaining `All Apps` grid:
|
||||
|
||||
```json
|
||||
{
|
||||
"storefront": {
|
||||
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
|
||||
"promotions": [{
|
||||
"id": "my-app",
|
||||
"banner": "/assets/img/featured/my-app.webp",
|
||||
"eyebrow": "open source",
|
||||
"headline": "Build together.",
|
||||
"description": "Catalog-controlled promotional copy.",
|
||||
"tag": "NOSTR // SOURCE",
|
||||
"path": "/npub1maintainer/project",
|
||||
"launchLabel": "Open",
|
||||
"installLabel": "Install",
|
||||
"detailsLabel": "Learn more →"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only IDs present in `apps` render. An optional promotion `path` deep-links into
|
||||
the installed app; Archipelago uses this to open the canonical signed Nostr
|
||||
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
|
||||
daemon-verified signed catalog and use the bundled community copy as a local
|
||||
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
|
||||
release artifact; changing it does not require a node OS release once that
|
||||
artifact is published.
|
||||
|
||||
## Categories
|
||||
|
||||
money, commerce, data, home, nostr, networking, community, development, l484
|
||||
|
||||
+37
-33
@@ -9,19 +9,31 @@
|
||||
"description": "Bitcoin documentaries with Nostr identity.",
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"storefront": {
|
||||
"popular": [
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"filebrowser",
|
||||
"homeassistant"
|
||||
],
|
||||
"promotions": [
|
||||
{
|
||||
"id": "archipelago-source",
|
||||
"banner": "/assets/img/featured/archipelago-source-banner.webp",
|
||||
"eyebrow": "open source",
|
||||
"headline": "Your node. Your source.",
|
||||
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
}
|
||||
]
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "adguardhome",
|
||||
"title": "AdGuard Home",
|
||||
"version": "v0.107.79",
|
||||
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
|
||||
"icon": "",
|
||||
"author": "AdGuard",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
|
||||
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
@@ -247,6 +259,19 @@
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "archipelago-source",
|
||||
"title": "GitWorkshop",
|
||||
"version": "0.4.0",
|
||||
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
|
||||
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
|
||||
"author": "GitWorkshop contributors",
|
||||
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
|
||||
"dockerImage": "localhost/archipelago-source:local"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
@@ -619,27 +644,6 @@
|
||||
"/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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-1
@@ -25,7 +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 |
|
||||
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
@@ -54,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
||||
| DID Wallet | http://localhost:18083 |
|
||||
| Router | http://localhost:18084 |
|
||||
| Meshtastic | http://localhost:14403 |
|
||||
| GitWorkshop | http://localhost:18337 |
|
||||
|
||||
## Port Conflict Resolution
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
app:
|
||||
id: adguardhome
|
||||
name: AdGuard Home
|
||||
version: v0.107.79
|
||||
upstream:
|
||||
kind: github
|
||||
repo: AdguardTeam/AdGuardHome
|
||||
description: >-
|
||||
Network-wide ad and tracker blocking: a DNS server that filters every
|
||||
device on your LAN, with a web console for rules and client management.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 3030
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
|
||||
# node, and both being installable means the host ports must not
|
||||
# collide (the orchestrator refuses/loads warn on overlap).
|
||||
# open: the setup wizard and admin console carry AdGuard Home's own
|
||||
# login; the gate fronts the port (TLS, header fixes) without a
|
||||
# second cookie challenge.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
AdGuard Home enforces its own admin login on the console, and the
|
||||
first-run wizard must answer before any account exists.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: udp
|
||||
# none: plain DNS must answer every unauthenticated query from LAN
|
||||
# devices — a login page in front of :53 breaks every client on the
|
||||
# network by design.
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Plain DNS answers unauthenticated by protocol: resolvers and clients
|
||||
send queries directly; a login challenge would make DNS unreachable.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
DNS-over-TCP fallback (truncated responses, zone transfers); same
|
||||
protocol-level requirement as the UDP port.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/adguardhome
|
||||
target: /opt/adguardhome
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3030
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Admin console
|
||||
description: AdGuard Home web console
|
||||
type: ui
|
||||
port: 3030
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: AdGuard
|
||||
category: networking
|
||||
repo: https://github.com/AdguardTeam/AdGuardHome
|
||||
tier: optional
|
||||
@@ -0,0 +1,80 @@
|
||||
app:
|
||||
id: archipelago-source
|
||||
name: GitWorkshop
|
||||
version: 0.4.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: DanConwayDev/gitworkshop
|
||||
description: >-
|
||||
Get Archipelago's source, clone it with ngit, and contribute issues,
|
||||
patches, and reviews over Nostr using the upstream GitWorkshop client.
|
||||
category: development
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/archipelago-source
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-source:local
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 64Mi
|
||||
disk_limit: 64Mi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: host
|
||||
|
||||
ports:
|
||||
- host: 8337
|
||||
container: 8337
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
session_passthrough: true
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8337
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: GitWorkshop
|
||||
description: NIP-34 repository browser, issues, pull requests, and review
|
||||
type: ui
|
||||
port: 8337
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
# Versioned filename deliberately invalidates dashboard/browser icon caches
|
||||
# when the Source prototype is replaced by the upstream GitWorkshop mark.
|
||||
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
|
||||
author: GitWorkshop contributors
|
||||
repo: https://github.com/DanConwayDev/gitworkshop
|
||||
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
|
||||
tier: optional
|
||||
launch:
|
||||
# GitWorkshop is top-level in Companion's native in-app WebView. Its
|
||||
# injected NIP-07 provider creates the authenticated dashboard-origin
|
||||
# signer broker itself, so no dashboard parent frame is required.
|
||||
requires_host_frame: false
|
||||
features:
|
||||
- NIP-34 repository discovery and browsing
|
||||
- Bandwidth-efficient Git explorer over GRASP
|
||||
- Nostr issues, pull requests, and code review
|
||||
- NIP-07 extension and NIP-46 remote-signer support
|
||||
- Archipelago node identity through explicit signing consent
|
||||
@@ -0,0 +1,67 @@
|
||||
app:
|
||||
id: cuprate-ui
|
||||
name: Cuprate UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||
inside a container, serves a static status dashboard, and proxies
|
||||
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||
published host port for the container's 18089). No credentials are
|
||||
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||
the nginx.conf is baked into the image and there is no rendered-config
|
||||
bind-mount like bitcoin-ui's.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/cuprate-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/cuprate-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: cuprate
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate would have no
|
||||
# idea the port existed: neither protected nor listed as unprotected.
|
||||
ports:
|
||||
- host: 18091
|
||||
container: 18091
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18091
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Archipelago
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
@@ -36,12 +36,26 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||
# when disk is scarce (see the DISK_GB branch in
|
||||
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||
# syncs until the filesystem fills and takes Archipelago down. The
|
||||
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||
# restart and update refuse, and boot reconcile skips, on any node under
|
||||
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||
# replace that gate with the bitcoin-style entrypoint branch.
|
||||
#
|
||||
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||
# (store size display, install pre-checks, docs) must show the number the
|
||||
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||
# value gets refused at a different, unexplained one. Single source of
|
||||
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||
- storage: 450Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
@@ -51,7 +65,9 @@ app:
|
||||
# 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
|
||||
# Matches the storage dependency above (= the enforced disk floor),
|
||||
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||
disk_limit: 450Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
+10
-2
@@ -16,11 +16,13 @@ app:
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 500Mi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
@@ -66,6 +68,12 @@ app:
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__server__LFS_START_SERVER=true
|
||||
- GITEA__packages__ENABLED=true
|
||||
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
|
||||
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
|
||||
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
|
||||
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
|
||||
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
|
||||
- GITEA__repository_0x2Erelease__MAX_FILES=20
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
|
||||
|
||||
|
||||
@@ -19,14 +19,15 @@ app:
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [api]
|
||||
# The JWT signing secret is owned here (no backend container owns it); the
|
||||
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
|
||||
# only consumed here. ensure_generated_secrets no-ops when a file already
|
||||
# exists, so live values on .228 are preserved (postgres pw is fixed at
|
||||
# PGDATA init — regenerating would lock the API out).
|
||||
# The JWT signing secret and stable envelope-encryption root are owned here;
|
||||
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
|
||||
# and only consumed here. Existing nodes migrate the legacy AES value into
|
||||
# the secret file once, while fresh nodes receive a unique per-node value.
|
||||
generated_secrets:
|
||||
- name: indeedhub-jwt
|
||||
kind: hex32
|
||||
- name: indeedhub-aes-master
|
||||
kind: hex16
|
||||
secret_env:
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: indeedhub-db-password
|
||||
@@ -34,6 +35,8 @@ app:
|
||||
secret_file: indeedhub-minio-password
|
||||
- key: NOSTR_JWT_SECRET
|
||||
secret_file: indeedhub-jwt
|
||||
- key: AES_MASTER_SECRET
|
||||
secret_file: indeedhub-aes-master
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-postgres
|
||||
@@ -67,9 +70,6 @@ app:
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- S3_PUBLIC_BUCKET_URL=/storage
|
||||
- NOSTR_JWT_EXPIRES_IN=7d
|
||||
# Fixed across the fleet (envelope-encryption master key baked by the legacy
|
||||
# installer); not node-specific, so a plain env literal, not a secret.
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
- ENVIRONMENT=production
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -22,6 +22,8 @@ app:
|
||||
secret_file: indeedhub-db-password
|
||||
- key: AWS_SECRET_KEY
|
||||
secret_file: indeedhub-minio-password
|
||||
- key: AES_MASTER_SECRET
|
||||
secret_file: indeedhub-aes-master
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-api
|
||||
@@ -51,4 +53,3 @@ app:
|
||||
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- ENVIRONMENT=production
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
|
||||
@@ -69,7 +69,10 @@ app:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
- exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = \/sw.js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["nginx", "-s", "reload"]
|
||||
|
||||
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.11-alpha"
|
||||
version = "1.8.14-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.11-alpha"
|
||||
version = "1.8.14-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
|
||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake).
|
||||
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5.3"
|
||||
|
||||
@@ -269,6 +269,8 @@ impl RpcHandler {
|
||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
|
||||
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
|
||||
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
|
||||
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
|
||||
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
|
||||
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
||||
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
||||
|
||||
@@ -55,6 +55,10 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
// The node's operational Nostr key is intentionally
|
||||
// distinguishable from user profile identities. Clients
|
||||
// must never offer it in app sign-in pickers.
|
||||
"is_node": is_node,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
|
||||
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
// auth.changePassword verifies the existing node password before it
|
||||
// writes either the web hash or the optional Linux/SSH password. This
|
||||
// is safe, actionable validation text; masking it as an internal
|
||||
// failure sent operators to the server logs for a simple typo.
|
||||
"Current password is incorrect",
|
||||
// OTA apply/download errors are all operator-actionable ("download it
|
||||
// again", "download first") — sanitizing them to "Operation failed"
|
||||
// left users stuck with no idea what to do, and hid the "already
|
||||
@@ -242,6 +247,12 @@ mod sanitize_tests {
|
||||
assert_eq!(sanitize_error_message(msg), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_rejection_reaches_the_operator() {
|
||||
let msg = "Current password is incorrect";
|
||||
assert_eq!(sanitize_error_message(msg), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tor_unavailable_precondition_passes_through() {
|
||||
let msg = "Tor address not available. Tor may not be running.";
|
||||
@@ -306,7 +317,7 @@ mod sanitize_tests {
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
/// and the on-disk remember secret (not ephemeral state).
|
||||
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -34,6 +34,7 @@ mod nostr;
|
||||
mod onboarding_gate;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
|
||||
// never added to it — the Phase-10 hard constraint this crate must hold.
|
||||
// The list's *contents* are unchanged; only its read-visibility widens from
|
||||
// "this module" to "this crate".
|
||||
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
|
||||
};
|
||||
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
|
||||
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// Browser apps run on dedicated high ports and can share the authenticated
|
||||
/// node cookie. Nostr signing must therefore be callable by the dashboard
|
||||
/// bridge (ports 80/443), not directly by an iframe that could bypass its
|
||||
/// consent dialog. Requests without Origin remain available to authenticated
|
||||
/// local CLI/integration clients. Development permits loopback origins.
|
||||
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
|
||||
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(url) = reqwest::Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return false;
|
||||
}
|
||||
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
|
||||
return true;
|
||||
}
|
||||
matches!(url.port_or_known_default(), Some(80 | 443))
|
||||
}
|
||||
|
||||
/// Read-only authenticated methods may skip CSRF, but they must still exist in
|
||||
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
|
||||
/// session probe, so keeping the policy in one testable function protects that
|
||||
/// cross-origin app-gate bootstrap contract.
|
||||
fn csrf_exempt_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"node-messages-received"
|
||||
| "server.echo"
|
||||
| "server.get-state"
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
| "system.get-metrics"
|
||||
| "system.get-hostname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
/// Dev builds only — the pre-setup login bypass that reads this is
|
||||
/// cfg-gated out of release binaries.
|
||||
@@ -291,6 +333,18 @@ impl RpcHandler {
|
||||
|
||||
debug!("RPC method: {}", rpc_req.method);
|
||||
|
||||
if matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node.nostr-sign" | "identity.nostr-sign"
|
||||
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
|
||||
{
|
||||
return Ok(self.error_response(
|
||||
403,
|
||||
"Nostr signing from app origins requires the dashboard consent bridge",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
let mut new_session_cookies: Option<(String, String)> = None;
|
||||
@@ -340,21 +394,7 @@ impl RpcHandler {
|
||||
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
||||
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
||||
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||
let csrf_exempt = matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node-messages-received"
|
||||
| "server.echo"
|
||||
| "server.get-state"
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
| "system.get-metrics"
|
||||
| "system.get-version"
|
||||
);
|
||||
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
|
||||
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
||||
let csrf_header = parts
|
||||
.headers
|
||||
@@ -735,3 +775,62 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nostr_signing_origin_tests {
|
||||
use super::*;
|
||||
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
|
||||
|
||||
fn headers(origin: Option<&str>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
|
||||
assert!(nostr_signing_origin_allowed(&headers(None), false));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local")),
|
||||
false
|
||||
));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("http://192.0.2.10")),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
|
||||
assert!(!nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local:8337")),
|
||||
false
|
||||
));
|
||||
assert!(!nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local:7778")),
|
||||
false
|
||||
));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("http://localhost:5173")),
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_probe_contract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
|
||||
const PROBE: &str = "system.get-hostname";
|
||||
const DISPATCHER: &str = include_str!("dispatcher.rs");
|
||||
|
||||
assert!(csrf_exempt_method(PROBE));
|
||||
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
|
||||
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
|
||||
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ impl RpcHandler {
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
@@ -199,10 +200,15 @@ impl RpcHandler {
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
|
||||
/// "mint_url": "<optional override>" }`
|
||||
/// "mint_url": "<optional override>",
|
||||
/// "payout_address": "<optional Lightning address>" }`
|
||||
///
|
||||
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
|
||||
/// mint that must be running as an Archy app before calling this endpoint.
|
||||
///
|
||||
/// `payout_address` sets the "owner" identity's Lightning address for
|
||||
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
|
||||
/// Omitted or blank leaves whatever's already on the router untouched.
|
||||
pub(super) async fn handle_openwrt_provision_tollgate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -240,12 +246,35 @@ impl RpcHandler {
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
|
||||
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
|
||||
// mint URL against this value with an exact string compare, and
|
||||
// Cashu wallets (Minibits included) encode mint URLs without a
|
||||
// trailing slash. A stray slash here means every otherwise-valid
|
||||
// token gets rejected as "untrusted mint" — confirmed live against
|
||||
// archy-x250-pa3 2026-09-07 with a manually-entered
|
||||
// "https://mint.minibits.cash/Bitcoin/".
|
||||
let mint_url = p
|
||||
.get("mint_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_mint_url)
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
// `None` (not sent, or sent blank) leaves whatever's already on the
|
||||
// router untouched — see apply_payout_identity's doc comment for why
|
||||
// that matters (an upstream-default placeholder otherwise survives
|
||||
// forever, since nothing else ever writes this field).
|
||||
let payout_address = p
|
||||
.get("payout_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
if let Some(address) = payout_address.as_deref() {
|
||||
tollgate::config::validate_payout_address(address)
|
||||
.context("invalid TollGate payout address")?;
|
||||
}
|
||||
|
||||
let config = TollGateConfig {
|
||||
ssid: "archipelago".to_string(),
|
||||
mint_url,
|
||||
@@ -256,6 +285,7 @@ impl RpcHandler {
|
||||
.unwrap_or(60_000),
|
||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
payout_address,
|
||||
};
|
||||
|
||||
// Blocking SSH session, and provision runs `opkg install` over it —
|
||||
|
||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
@@ -294,6 +295,12 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
// Update is stop → pull → remove → recreate, i.e. a fresh start by
|
||||
// another name: on a disk that shrank since install it would resume
|
||||
// cuprate's unprunable sync unchecked. Same gate as install and
|
||||
// start, run BEFORE the Updating flip so a refusal leaves the app
|
||||
// cleanly in its previous state.
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
|
||||
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
|
||||
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
|
||||
/// shared with the boot reconciler so install/start and boot can never
|
||||
/// disagree about where cuprate may run.
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
|
||||
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
|
||||
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
|
||||
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
|
||||
/// to run cuprate at all rather than let it sync until the filesystem fills —
|
||||
/// which took Archipelago itself down on nodes with too little disk.
|
||||
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
|
||||
format!(
|
||||
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
|
||||
A Monero node cannot run pruned — upstream cuprate has no pruning \
|
||||
support — so the chain (~250 GB and growing) would fill the disk and \
|
||||
take Archipelago down with it. Attach a larger disk (or move \
|
||||
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
|
||||
pruned on smaller disks; Monero currently cannot.",
|
||||
CUPRATE_MIN_DISK_GB, disk_gb
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure decision half of the cuprate disk gate — testable without df.
|
||||
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
|
||||
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
|
||||
}
|
||||
|
||||
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
|
||||
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
|
||||
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
|
||||
/// when df fails, so an unreadable disk never blocks an install).
|
||||
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
|
||||
if package_id != "cuprate" {
|
||||
return Ok(());
|
||||
}
|
||||
let disk_gb = detect_disk_gb().await;
|
||||
if let Some(message) = cuprate_disk_gate(disk_gb) {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
|
||||
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
|
||||
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
|
||||
};
|
||||
use archipelago_container::Dependency;
|
||||
|
||||
@@ -1017,6 +1061,37 @@ mod tests {
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
|
||||
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
|
||||
assert!(cuprate_disk_gate(0).is_some());
|
||||
assert!(cuprate_disk_gate(250).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
|
||||
assert!(cuprate_disk_gate(1000).is_none());
|
||||
// df failure reads as u64::MAX — an unreadable disk must not block.
|
||||
assert!(cuprate_disk_gate(u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
|
||||
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
|
||||
assert!(msg.contains("cannot run pruned"), "{msg}");
|
||||
assert!(msg.contains("larger disk"), "{msg}");
|
||||
assert!(msg.contains("250 GB"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cuprate_disk_gate_only_applies_to_cuprate() {
|
||||
// Every other package passes regardless of disk — including the
|
||||
// bitcoin apps, which self-prune via their manifest entrypoint.
|
||||
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
super::check_cuprate_disk_compatibility(package_id)
|
||||
.await
|
||||
.expect("non-cuprate installs must not be gated here");
|
||||
}
|
||||
}
|
||||
|
||||
mod dep_wait {
|
||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
@@ -3,10 +3,10 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn patch_indeedhub_nostr_provider() {
|
||||
fn patched_indeedhub_nginx_config(original: &str) -> String {
|
||||
let mut conf = original
|
||||
.lines()
|
||||
.filter(|line| !line.contains("X-Frame-Options"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
conf.push('\n');
|
||||
if !conf.contains("location = /nostr-provider.js {") {
|
||||
conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
expires off;\n\
|
||||
}\n\n\
|
||||
location = /sw.js {",
|
||||
);
|
||||
}
|
||||
if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf = conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
|
||||
1,
|
||||
);
|
||||
}
|
||||
conf = conf.replace(
|
||||
"src=\"/nostr-provider.js\"",
|
||||
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
|
||||
);
|
||||
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
|
||||
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
|
||||
conf.replace(
|
||||
"proxy_set_header X-Forwarded-Prefix /api;",
|
||||
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn patch_indeedhub_nostr_provider() {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"/X-Frame-Options/d",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
// Frontend assets can change during a dashboard-only OTA while the
|
||||
// IndeedHub container keeps running. Reconcile the injected provider on
|
||||
// daemon startup as well as app install/start, but stay quiet when the app
|
||||
// is not installed or is intentionally stopped.
|
||||
let running = tokio::process::Command::new("podman")
|
||||
.args(["inspect", "-f", "{{.State.Running}}", "indeedhub"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"cp",
|
||||
provider_src,
|
||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
.await
|
||||
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
|
||||
.unwrap_or(false);
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"grep",
|
||||
"-q",
|
||||
"nostr-provider",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
// `podman exec` cannot always join a rootless container's delegated cgroup
|
||||
// from the system service, while Podman 5's copier refuses to overwrite an
|
||||
// existing regular file. Mount the rootless storage namespace instead;
|
||||
// this replaces both files without entering the container's cgroup.
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp_dir = format!("/tmp/indeedhub-nginx-patch-{}-{unique}", std::process::id());
|
||||
let tmp_path = format!("{tmp_dir}/default.conf");
|
||||
if tokio::fs::create_dir(&tmp_dir).await.is_err() {
|
||||
tracing::warn!("IndeeHub signer reconciliation could not create its temporary directory");
|
||||
return;
|
||||
}
|
||||
|
||||
let mount_out = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "podman", "mount", "indeedhub"])
|
||||
.output()
|
||||
.await;
|
||||
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
|
||||
let container_root = mount_out
|
||||
.ok()
|
||||
.filter(|out| out.status.success())
|
||||
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
.filter(|path| {
|
||||
std::path::Path::new(path).is_absolute()
|
||||
&& path.contains("/containers/storage/overlay/")
|
||||
&& path.ends_with("/merged")
|
||||
});
|
||||
let Some(container_root) = container_root else {
|
||||
let _ = tokio::fs::remove_dir(&tmp_dir).await;
|
||||
tracing::warn!("IndeeHub signer reconciliation could not mount rootless storage");
|
||||
return;
|
||||
};
|
||||
|
||||
if !already_patched {
|
||||
let cat_out = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
|
||||
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
|
||||
&& tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"unshare",
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
provider_src,
|
||||
&provider_dest,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
.await
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Ok(out) = cat_out {
|
||||
if out.status.success() {
|
||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
expires off;\n\
|
||||
}\n\n\
|
||||
location = /sw.js {",
|
||||
);
|
||||
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
conf
|
||||
};
|
||||
let copy_out = tokio::process::Command::new("podman")
|
||||
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
||||
let mut config_copied = false;
|
||||
if let Ok(out) = copy_out {
|
||||
if out.status.success() {
|
||||
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
|
||||
let conf = patched_indeedhub_nginx_config(&original);
|
||||
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
|
||||
config_copied = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"unshare",
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&tmp_path,
|
||||
&format!("{container_root}/etc/nginx/conf.d/default.conf"),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(tmp_path).await;
|
||||
.await
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false);
|
||||
if config_copied {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
config_copied = tokio::process::Command::new("podman")
|
||||
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
&& tokio::fs::read_to_string(&tmp_path)
|
||||
.await
|
||||
.map(|actual| actual == conf)
|
||||
.unwrap_or(false);
|
||||
}
|
||||
} else if conf == original
|
||||
&& conf.contains("location = /nostr-provider.js {")
|
||||
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
|
||||
{
|
||||
config_copied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
let _ = tokio::fs::remove_dir(&tmp_dir).await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.args(["unshare", "podman", "unmount", "indeedhub"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let reload = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
||||
.args(["kill", "--signal", "HUP", "indeedhub"])
|
||||
.output()
|
||||
.await;
|
||||
match reload {
|
||||
Ok(o) if o.status.success() => {
|
||||
Ok(o) if o.status.success() && provider_copied && config_copied => {
|
||||
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
"IndeeHub nginx reload failed: {}",
|
||||
"IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}",
|
||||
provider_copied,
|
||||
config_copied,
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
}
|
||||
@@ -306,6 +374,7 @@ impl RpcHandler {
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
check_cuprate_disk_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
@@ -1620,124 +1689,10 @@ autopilot.active=false\n",
|
||||
}
|
||||
}
|
||||
|
||||
// IndeeHub: inject nostr-provider.js and patch container nginx for NIP-07 signing
|
||||
// IndeeHub: inject the current consent-gated provider and make it work
|
||||
// in both the dashboard frame and a direct browser tab.
|
||||
if package_id == "indeedhub" {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
// 1. Remove X-Frame-Options so iframe embedding works
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"/X-Frame-Options/d",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 2. Copy nostr-provider.js into container
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"cp",
|
||||
provider_src,
|
||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// 3. Add nostr-provider.js location block + sub_filter injection
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"grep",
|
||||
"-q",
|
||||
"nostr-provider",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if !already_patched {
|
||||
// Read current nginx config from container
|
||||
let cat_out = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(out) = cat_out {
|
||||
if out.status.success() {
|
||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
|
||||
// Insert provider location block before the sw.js location
|
||||
let conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
\x20 add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
\x20 expires off;\n\
|
||||
\x20 }\n\n\
|
||||
\x20 location = /sw.js {"
|
||||
);
|
||||
|
||||
// Inject script tag into HTML via sub_filter
|
||||
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
\x20 sub_filter_once on;\n\
|
||||
\x20 sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
conf
|
||||
};
|
||||
|
||||
// Write patched config back into container
|
||||
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(tmp_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fix X-Forwarded-Prefix for NIP-98 URL reconstruction in iframe context
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "sed", "-i",
|
||||
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
|
||||
"/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 5. Reload nginx to apply changes
|
||||
let reload = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
||||
.output()
|
||||
.await;
|
||||
match reload {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
"IndeeHub nginx reload failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("IndeeHub nginx reload error: {}", e);
|
||||
}
|
||||
}
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
||||
@@ -2789,6 +2744,11 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
|
||||
| "gitea"
|
||||
| "portainer"
|
||||
| "meshtastic"
|
||||
// Build-backed user-facing app. Route it through the production
|
||||
// orchestrator so a fresh node builds its bundled image instead
|
||||
// of treating localhost/archipelago-source:local as a registry
|
||||
// image in the legacy installer.
|
||||
| "archipelago-source"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2800,11 +2760,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
|
||||
uses_orchestrator_install_flow,
|
||||
orchestrator_install_app_id, parse_setup_token, patched_indeedhub_nginx_config,
|
||||
should_try_orchestrator_install, uses_orchestrator_install_flow,
|
||||
};
|
||||
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
|
||||
|
||||
#[test]
|
||||
fn indeedhub_nginx_patch_is_complete_and_idempotent() {
|
||||
let original = r#"server {
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
location = /sw.js {
|
||||
expires off;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_set_header X-Forwarded-Prefix /api;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let patched = patched_indeedhub_nginx_config(original);
|
||||
assert!(!patched.contains("X-Frame-Options"));
|
||||
assert!(patched.contains("location = /nostr-provider.js {"));
|
||||
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
|
||||
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\""));
|
||||
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
|
||||
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
|
||||
|
||||
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
|
||||
let migrated = patched_indeedhub_nginx_config(&previous_broker);
|
||||
assert!(migrated.contains("tab-signer-v4"));
|
||||
assert!(!migrated.contains("tab-signer-v3"));
|
||||
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_install_allowlist_includes_ported_backends() {
|
||||
for app in [
|
||||
@@ -2836,6 +2828,7 @@ mod tests {
|
||||
"gitea",
|
||||
"portainer",
|
||||
"meshtastic",
|
||||
"archipelago-source",
|
||||
] {
|
||||
assert!(uses_orchestrator_install_flow(app));
|
||||
assert!(should_try_orchestrator_install(app, true));
|
||||
|
||||
@@ -4,6 +4,7 @@ mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use install::patch_indeedhub_nostr_provider;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
|
||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// A cuprate node that starts on a too-small disk fills it and takes
|
||||
// Archipelago down with it (no upstream pruning — see
|
||||
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||
// before clearing user-stopped or flipping state, so the app stays
|
||||
// cleanly stopped and the error carries the actionable message.
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
@@ -251,6 +257,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||
// minimum after install it resumes the doomed unprunable sync just like
|
||||
// start would — same gate, same "fail before clearing user-stopped /
|
||||
// flipping state" contract (see handle_package_start).
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
|
||||
@@ -1559,6 +1559,31 @@ impl RpcHandler {
|
||||
self.set_install_progress("indeedhub", n_images, n_images)
|
||||
.await;
|
||||
|
||||
// The retired installer injected one fleet-wide AES root directly in
|
||||
// the API/worker environment. Detect those consumers before removing
|
||||
// anything, then persist the legacy value exactly once so an upgrade
|
||||
// cannot orphan encrypted data. A genuinely fresh fallback install
|
||||
// receives a random per-node root instead.
|
||||
let mut had_existing_crypto_consumer = false;
|
||||
for name in [
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub-build_api_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
] {
|
||||
let status =
|
||||
podman_stack_status(&["container", "exists", name], PODMAN_STACK_PROBE_TIMEOUT)
|
||||
.await?;
|
||||
had_existing_crypto_consumer |= status.success();
|
||||
}
|
||||
let secrets_dir = self.config.data_dir.join("secrets");
|
||||
crate::container::secrets::ensure_indeedhub_aes_master_secret(
|
||||
&secrets_dir,
|
||||
had_existing_crypto_consumer,
|
||||
)
|
||||
.context("preparing IndeedHub encryption root")?;
|
||||
let aes_master = crate::container::secrets::indeedhub_aes_master_secret(&secrets_dir)?;
|
||||
|
||||
// Remove any leftover containers from a previous partial install (or
|
||||
// from the first-boot frontend stub that used to race the installer).
|
||||
// Without this, `podman run --name indeedhub` fails on name conflict
|
||||
@@ -1759,7 +1784,7 @@ impl RpcHandler {
|
||||
"-e".to_string(),
|
||||
"NOSTR_JWT_EXPIRES_IN=7d".to_string(),
|
||||
"-e".to_string(),
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
|
||||
format!("AES_MASTER_SECRET={aes_master}"),
|
||||
"-e".to_string(),
|
||||
"ENVIRONMENT=production".to_string(),
|
||||
format!("{registry}/indeedhub-api:1.0.0"),
|
||||
@@ -1810,7 +1835,7 @@ impl RpcHandler {
|
||||
"-e".to_string(),
|
||||
"ENVIRONMENT=production".to_string(),
|
||||
"-e".to_string(),
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
|
||||
format!("AES_MASTER_SECRET={aes_master}"),
|
||||
format!("{registry}/indeedhub-ffmpeg:1.0.0"),
|
||||
],
|
||||
&tmp_env,
|
||||
|
||||
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod known_service_tests {
|
||||
use super::{is_protocol_service, known_service_port};
|
||||
|
||||
#[test]
|
||||
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
|
||||
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
|
||||
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
|
||||
// here means auto-enrollment silently skips it (known_service_port
|
||||
// returns 0) and, separately, regenerate_torrc falls back to the
|
||||
// web-app HiddenServicePort-80 default instead of forwarding 8333
|
||||
// straight through.
|
||||
assert_eq!(known_service_port("bitcoin-core"), 8333);
|
||||
assert!(is_protocol_service("bitcoin-core"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod torrc_tests {
|
||||
use super::app_hidden_service_port_line;
|
||||
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
|
||||
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
||||
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +421,33 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
|
||||
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
|
||||
/// ecash wallet's own seed. Registers the profile on first use; safe to call
|
||||
/// on every open of the Cashu receive screen (it is idempotent).
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
|
||||
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
|
||||
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
|
||||
/// (0 when nothing was waiting), so the UI can refresh its balance.
|
||||
/// `failed_count` is non-zero when a payment was fetched (and so already
|
||||
/// consumed server-side) but couldn't be redeemed yet — it stays queued
|
||||
/// and is retried automatically, but the UI should tell the operator
|
||||
/// rather than let it be a silent, unbounded wait.
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
|
||||
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"claimed_count": outcome.claimed_count,
|
||||
"received_sats": outcome.received_sats,
|
||||
"failed_count": outcome.failed_count,
|
||||
"receipt_id": outcome.receipt_id,
|
||||
"receipt_sats": outcome.receipt_sats,
|
||||
"receipt_at": outcome.receipt_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -148,9 +148,16 @@ impl AppGate {
|
||||
let app = live.as_ref().unwrap_or(app);
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
|
||||
// sees the URI. Carry that trusted proxy mount into the challenge's
|
||||
// form/assets and its post-login redirect so the browser stays inside
|
||||
// the mounted app instead of posting to the dashboard root.
|
||||
let mount_prefix = forwarded_mount_prefix(req.headers());
|
||||
|
||||
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
|
||||
return self.handle_gate_action(req, app, action, client_ip).await;
|
||||
return self
|
||||
.handle_gate_action(req, app, action, client_ip, &mount_prefix)
|
||||
.await;
|
||||
}
|
||||
|
||||
// A browser fetches a few subresources WITHOUT credentials by
|
||||
@@ -188,10 +195,25 @@ impl AppGate {
|
||||
return proxy_to_app(req, app, false).await;
|
||||
}
|
||||
|
||||
// Capture the platform session before the request is moved into the
|
||||
// upstream proxy. Older app-gate sessions (issued before the paired
|
||||
// CSRF-cookie fix) can then repair themselves on the very next app
|
||||
// response, before the app's provider creates its signer iframe.
|
||||
let session_for_csrf = crate::session::extract_session_cookie(req.headers());
|
||||
let needs_csrf_cookie = cookie_value(req.headers(), "csrf_token").is_none();
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
// The credential was a cookie (or none was needed): the
|
||||
// Authorization header, if any, belongs to the app. Forward it.
|
||||
Authorization::Allow => proxy_to_app(req, app, false).await,
|
||||
Authorization::Allow => {
|
||||
let mut response = proxy_to_app(req, app, false).await;
|
||||
if needs_csrf_cookie {
|
||||
if let Some(token) = session_for_csrf {
|
||||
set_csrf_cookie(&mut response, &token).await;
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
// The credential WAS the Authorization header, and it was ours.
|
||||
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
|
||||
// 401 rather than a redirect: a redirect to a login page is
|
||||
@@ -199,7 +221,9 @@ impl AppGate {
|
||||
// clients would follow it and parse HTML as if it were their API
|
||||
// response. The status says "you are not authenticated" in a way
|
||||
// every client understands, and browsers still render the body.
|
||||
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
|
||||
Authorization::Challenge => {
|
||||
login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +253,7 @@ impl AppGate {
|
||||
app: &GatedPort,
|
||||
action: &str,
|
||||
client_ip: IpAddr,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
// Assets are GET and pre-auth by nature: the login page cannot
|
||||
// render its own background or logo without them.
|
||||
@@ -236,7 +261,7 @@ impl AppGate {
|
||||
return self.serve_asset(name);
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
return login_page(app, None, StatusCode::OK);
|
||||
return login_page(app, None, StatusCode::OK, mount_prefix);
|
||||
}
|
||||
|
||||
// Captured before the body is consumed. The pending-2FA session
|
||||
@@ -253,17 +278,28 @@ impl AppGate {
|
||||
app,
|
||||
Some("Too many attempts. Wait a minute and try again."),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
mount_prefix,
|
||||
);
|
||||
}
|
||||
|
||||
let form = match read_form(req).await {
|
||||
Some(form) => form,
|
||||
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
|
||||
None => {
|
||||
return login_page(
|
||||
app,
|
||||
Some("Malformed request."),
|
||||
StatusCode::BAD_REQUEST,
|
||||
mount_prefix,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
match action {
|
||||
"login" => self.do_login(app, &form, client_ip).await,
|
||||
"totp" => self.do_totp(app, &form, pending, client_ip).await,
|
||||
"login" => self.do_login(app, &form, client_ip, mount_prefix).await,
|
||||
"totp" => {
|
||||
self.do_totp(app, &form, pending, client_ip, mount_prefix)
|
||||
.await
|
||||
}
|
||||
_ => not_found(),
|
||||
}
|
||||
}
|
||||
@@ -288,14 +324,25 @@ impl AppGate {
|
||||
.expect("asset response builds")
|
||||
}
|
||||
|
||||
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
|
||||
async fn do_login(
|
||||
&self,
|
||||
app: &GatedPort,
|
||||
form: &Form,
|
||||
client_ip: IpAddr,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
let password = field(form, "password").unwrap_or_default();
|
||||
|
||||
match self.auth.verify_password(&password).await {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
self.limiter.record_failure(client_ip).await;
|
||||
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
|
||||
return login_page(
|
||||
app,
|
||||
Some("Incorrect password."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
mount_prefix,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,8 +354,8 @@ impl AppGate {
|
||||
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
|
||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
|
||||
let pending = self.sessions.create_pending(secret).await;
|
||||
let mut resp = totp_page(app, None, StatusCode::OK);
|
||||
set_session_cookie(&mut resp, &pending);
|
||||
let mut resp = totp_page(app, None, StatusCode::OK, mount_prefix);
|
||||
set_session_cookie(&mut resp, &pending).await;
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -319,12 +366,13 @@ impl AppGate {
|
||||
app,
|
||||
Some("Two-factor data could not be read. Sign in from the dashboard."),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
mount_prefix,
|
||||
);
|
||||
}
|
||||
|
||||
let token = self.sessions.create().await;
|
||||
let mut resp = redirect_to_app();
|
||||
set_session_cookie(&mut resp, &token);
|
||||
let mut resp = redirect_to_app(mount_prefix);
|
||||
set_session_cookie(&mut resp, &token).await;
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -334,10 +382,16 @@ impl AppGate {
|
||||
form: &Form,
|
||||
pending: Option<String>,
|
||||
client_ip: IpAddr,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
let code = field(form, "code").unwrap_or_default();
|
||||
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
|
||||
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
|
||||
return login_page(
|
||||
app,
|
||||
Some("Session expired."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
mount_prefix,
|
||||
);
|
||||
};
|
||||
|
||||
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
|
||||
@@ -345,6 +399,7 @@ impl AppGate {
|
||||
app,
|
||||
Some("Session expired. Start again."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
mount_prefix,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -371,17 +426,27 @@ impl AppGate {
|
||||
}
|
||||
match self.sessions.upgrade_to_full(&pending).await {
|
||||
Some(full) => {
|
||||
let mut resp = redirect_to_app();
|
||||
set_session_cookie(&mut resp, &full);
|
||||
let mut resp = redirect_to_app(mount_prefix);
|
||||
set_session_cookie(&mut resp, &full).await;
|
||||
resp
|
||||
}
|
||||
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
|
||||
None => login_page(
|
||||
app,
|
||||
Some("Session expired."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
mount_prefix,
|
||||
),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.limiter.record_failure(client_ip).await;
|
||||
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
|
||||
set_session_cookie(&mut resp, &pending);
|
||||
let mut resp = totp_page(
|
||||
app,
|
||||
Some("Incorrect code."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
mount_prefix,
|
||||
);
|
||||
set_session_cookie(&mut resp, &pending).await;
|
||||
resp
|
||||
}
|
||||
}
|
||||
@@ -634,7 +699,7 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
async fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
|
||||
// which is what makes one sign-in cover the dashboard and every app port
|
||||
// on the same host — and equally why an app on a *different* host (its
|
||||
@@ -644,12 +709,82 @@ fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
{
|
||||
resp.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
|
||||
// The dashboard RPC layer requires a readable CSRF cookie as well as the
|
||||
// HttpOnly session cookie. An app-gate login is a complete node login, so
|
||||
// it must establish the same pair as auth.login; otherwise a fresh browser
|
||||
// can open the signer broker but every identity/signing RPC is rejected
|
||||
// with `has_session=true, has_header=false`.
|
||||
set_csrf_cookie(resp, token).await;
|
||||
}
|
||||
|
||||
fn redirect_to_app() -> Response<Body> {
|
||||
async fn set_csrf_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
let csrf = crate::api::rpc::derive_csrf_token(token).await;
|
||||
if let Ok(value) =
|
||||
header::HeaderValue::from_str(&format!("csrf_token={csrf}; SameSite=Lax; Path=/"))
|
||||
{
|
||||
resp.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let prefix = format!("{name}=");
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(';'))
|
||||
.map(str::trim)
|
||||
.find_map(|pair| pair.strip_prefix(&prefix))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Validate the mount supplied by the node's own nginx proxy.
|
||||
///
|
||||
/// Treat this as untrusted input even though our canonical proxy sets it: a
|
||||
/// client can reach an app-gate port directly and forge request headers. Only
|
||||
/// a short absolute path made from ordinary URL-path characters is accepted;
|
||||
/// protocol-relative URLs, dot segments, escaping and query/fragment syntax
|
||||
/// all fall back to the direct-port root.
|
||||
fn forwarded_mount_prefix(headers: &HeaderMap) -> String {
|
||||
let Some(raw) = headers
|
||||
.get("x-forwarded-prefix")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
let value = raw.trim_end_matches('/');
|
||||
if value.is_empty()
|
||||
|| value.len() > 256
|
||||
|| !value.starts_with('/')
|
||||
|| value.starts_with("//")
|
||||
|| value
|
||||
.bytes()
|
||||
.any(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')))
|
||||
|| value
|
||||
.split('/')
|
||||
.skip(1)
|
||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|
||||
{
|
||||
return String::new();
|
||||
}
|
||||
value.to_owned()
|
||||
}
|
||||
|
||||
fn gate_url(mount_prefix: &str, action: &str) -> String {
|
||||
format!("{mount_prefix}{GATE_PREFIX}{action}")
|
||||
}
|
||||
|
||||
fn redirect_to_app(mount_prefix: &str) -> Response<Body> {
|
||||
let location = if mount_prefix.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("{mount_prefix}/")
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/")
|
||||
.header(header::LOCATION, location)
|
||||
.body(Body::empty())
|
||||
.expect("static response builds")
|
||||
}
|
||||
@@ -674,7 +809,13 @@ dashboard and check {name} under My Apps.</p>"#,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
);
|
||||
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
|
||||
let mut resp = page(
|
||||
"App not responding",
|
||||
app,
|
||||
&body,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"",
|
||||
);
|
||||
// Header-based refresh, not <meta> or script: page()'s CSP allows no
|
||||
// script, and the header keeps the retry out of the document entirely.
|
||||
resp.headers_mut()
|
||||
@@ -707,7 +848,7 @@ fn esc(s: &str) -> String {
|
||||
/// the app's own port, so any asset URL would either hit the unauthenticated
|
||||
/// app behind it or a different origin the browser may not reach.
|
||||
/// One stacked layer per background, each delayed so they cross-fade in turn.
|
||||
fn background_layers() -> String {
|
||||
fn background_layers(mount_prefix: &str) -> String {
|
||||
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
|
||||
LOGIN_BACKGROUNDS
|
||||
.iter()
|
||||
@@ -715,7 +856,7 @@ fn background_layers() -> String {
|
||||
.map(|(i, name)| {
|
||||
format!(
|
||||
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
|
||||
prefix = GATE_PREFIX,
|
||||
prefix = gate_url(mount_prefix, ""),
|
||||
delay = i as u32 * step,
|
||||
)
|
||||
})
|
||||
@@ -938,7 +1079,13 @@ fn base64_encode(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
|
||||
fn page(
|
||||
title: &str,
|
||||
app: &GatedPort,
|
||||
body: &str,
|
||||
status: StatusCode,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en"><head>
|
||||
@@ -1055,7 +1202,7 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
|
||||
app_name = esc(&app.app_name),
|
||||
body = body,
|
||||
submit_feedback = SUBMIT_FEEDBACK_JS,
|
||||
backgrounds = background_layers(),
|
||||
backgrounds = background_layers(mount_prefix),
|
||||
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
||||
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
||||
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
|
||||
@@ -1092,7 +1239,12 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
|
||||
/// The challenge. Names and pictures the app being opened, so the visitor can
|
||||
/// confirm what they are authenticating to rather than being asked for a
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
fn login_page(
|
||||
app: &GatedPort,
|
||||
error: Option<&str>,
|
||||
status: StatusCode,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{logo}
|
||||
{icon}
|
||||
@@ -1110,14 +1262,19 @@ fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respo
|
||||
err = error
|
||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||
.unwrap_or_default(),
|
||||
prefix = GATE_PREFIX,
|
||||
prefix = gate_url(mount_prefix, ""),
|
||||
);
|
||||
page("Sign in", app, &body, status)
|
||||
page("Sign in", app, &body, status, mount_prefix)
|
||||
}
|
||||
|
||||
/// Second factor. Reached only after the password verified, and the session
|
||||
/// backing it cannot authorise anything until this completes.
|
||||
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
fn totp_page(
|
||||
app: &GatedPort,
|
||||
error: Option<&str>,
|
||||
status: StatusCode,
|
||||
mount_prefix: &str,
|
||||
) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
<h1>Two-factor code</h1>
|
||||
@@ -1133,9 +1290,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
|
||||
err = error
|
||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||
.unwrap_or_default(),
|
||||
prefix = GATE_PREFIX,
|
||||
prefix = gate_url(mount_prefix, ""),
|
||||
);
|
||||
page("Two-factor", app, &body, status)
|
||||
page("Two-factor", app, &body, status, mount_prefix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1207,9 +1364,35 @@ mod tests {
|
||||
assert_eq!(bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_mount_prefix_accepts_only_a_safe_absolute_path() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-prefix",
|
||||
"/app/archipelago-source/".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(forwarded_mount_prefix(&headers), "/app/archipelago-source");
|
||||
|
||||
for unsafe_value in [
|
||||
"//other.example/app",
|
||||
"/app/../admin",
|
||||
"/app//source",
|
||||
"/app/source?next=//other.example",
|
||||
"https://other.example/app",
|
||||
"/app/%2e%2e/admin",
|
||||
] {
|
||||
headers.insert("x-forwarded-prefix", unsafe_value.parse().unwrap());
|
||||
assert_eq!(
|
||||
forwarded_mount_prefix(&headers),
|
||||
"",
|
||||
"accepted {unsafe_value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_names_the_app() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
@@ -1222,7 +1405,7 @@ mod tests {
|
||||
async fn page_escapes_app_names() {
|
||||
let mut app = app();
|
||||
app.app_name = r#"<script>alert(1)</script>"#.to_string();
|
||||
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED);
|
||||
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED, "");
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(!html.contains("<script>alert"));
|
||||
@@ -1235,6 +1418,7 @@ mod tests {
|
||||
&app(),
|
||||
Some("<img src=x onerror=1>"),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"",
|
||||
);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
@@ -1293,7 +1477,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
|
||||
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(
|
||||
!resp.headers().contains_key("X-Frame-Options"),
|
||||
@@ -1329,7 +1513,7 @@ mod tests {
|
||||
/// never 404 at all.
|
||||
#[tokio::test]
|
||||
async fn login_page_sources_its_art_from_the_gate() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body).to_string();
|
||||
assert_eq!(
|
||||
@@ -1345,13 +1529,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_login_keeps_forms_assets_and_redirect_inside_the_app() {
|
||||
let mount = "/app/archipelago-source";
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, mount);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains(r#"action="/app/archipelago-source/__archipelago-gate/login""#));
|
||||
for name in LOGIN_BACKGROUNDS {
|
||||
assert!(html.contains(&format!("/app/archipelago-source{GATE_PREFIX}asset/{name}")));
|
||||
}
|
||||
|
||||
let redirect = redirect_to_app(mount);
|
||||
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
redirect.headers()[header::LOCATION],
|
||||
"/app/archipelago-source/"
|
||||
);
|
||||
}
|
||||
|
||||
/// The only script the challenge pages may run is the submit-feedback
|
||||
/// snippet, admitted by hash. The page must carry exactly that script,
|
||||
/// and the CSP must name its hash — anything injected has a different
|
||||
/// hash and stays inert.
|
||||
#[tokio::test]
|
||||
async fn submit_feedback_script_is_present_and_hash_pinned() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
|
||||
let csp = resp.headers()["Content-Security-Policy"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
@@ -1455,6 +1658,54 @@ mod tests {
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_finds_only_a_nonempty_named_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"app_session=keep; csrf_token=csrf123; empty="
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_value(&headers, "csrf_token"),
|
||||
Some("csrf123".to_string())
|
||||
);
|
||||
assert_eq!(cookie_value(&headers, "session"), None);
|
||||
assert_eq!(cookie_value(&headers, "empty"), None);
|
||||
}
|
||||
|
||||
/// An app-gate login must be equivalent to a dashboard login. The session
|
||||
/// cookie alone can load the broker route, but every identity/signing RPC
|
||||
/// also needs the matching readable CSRF cookie.
|
||||
#[tokio::test]
|
||||
async fn app_gate_login_establishes_the_dashboard_csrf_cookie() {
|
||||
let token = "app-gate-session-token";
|
||||
let mut resp = redirect_to_app("");
|
||||
|
||||
set_session_cookie(&mut resp, token).await;
|
||||
|
||||
let cookies: Vec<_> = resp
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.collect();
|
||||
let expected_csrf = crate::api::rpc::derive_csrf_token(token).await;
|
||||
assert!(cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with(&format!("session={token};"))));
|
||||
assert!(cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with(&format!("csrf_token={expected_csrf};"))));
|
||||
assert!(cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("session=") && cookie.contains("HttpOnly")));
|
||||
assert!(cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("HttpOnly")));
|
||||
}
|
||||
|
||||
/// The regression that killed every Nostr login on 2026-08-06.
|
||||
///
|
||||
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
|
||||
|
||||
@@ -26,7 +26,7 @@ const DOCTOR_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
|
||||
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
|
||||
|
||||
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
|
||||
const DOCTOR_SH_PATH: &str = "/opt/archipelago/scripts/container-doctor.sh";
|
||||
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
@@ -85,6 +85,15 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
/// image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
|
||||
|
||||
const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n";
|
||||
|
||||
const NGINX_SOURCE_PROXY_BLOCK_SNIPPET: &str = "# GitWorkshop follows the dashboard origin; the app gate keeps the route\n# session-authenticated before it reaches the loopback-only container.\nlocation /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n}\n";
|
||||
|
||||
/// The normal dashboard sends X-Frame-Options SAMEORIGIN. This one document
|
||||
/// must be frameable by an app on another port of the same node so tabs and
|
||||
/// companion WebViews can use the same authenticated signer UI.
|
||||
const NGINX_NOSTR_SIGNER_BLOCK: &str = " # Dashboard-origin Nostr signer for tab/WebView apps.\n location = /nostr-signer {\n try_files /index.html =404;\n add_header Cache-Control \"no-store\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n add_header Referrer-Policy \"no-referrer\" always;\n add_header Content-Security-Policy \"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' http://$host:* https://$host:*; base-uri 'none'; form-action 'none';\" always;\n }\n\n";
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
|
||||
@@ -1231,7 +1240,7 @@ async fn run() -> Result<bool> {
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// 1. Script — lives in archipelago's home dir, user-writable.
|
||||
// 1. Script — lives in the canonical OTA runtime scripts directory.
|
||||
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
|
||||
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
|
||||
.await
|
||||
@@ -1580,6 +1589,62 @@ fn heal_stale_web_search_block(content: &str) -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn heal_missing_source_proxy(content: &str) -> Option<String> {
|
||||
if content.contains("location /app/archipelago-source/") {
|
||||
return None;
|
||||
}
|
||||
let indented_anchor = " location /app/gitea/ {";
|
||||
if content.contains(indented_anchor) {
|
||||
return Some(content.replace(
|
||||
indented_anchor,
|
||||
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK, indented_anchor),
|
||||
));
|
||||
}
|
||||
let snippet_anchor = "location /app/gitea/ {";
|
||||
content.contains(snippet_anchor).then(|| {
|
||||
content.replace(
|
||||
snippet_anchor,
|
||||
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK_SNIPPET, snippet_anchor),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Older same-origin GitWorkshop blocks stripped the app mount but did not
|
||||
/// tell the app gate what was stripped. Its challenge therefore posted to
|
||||
/// `/__archipelago-gate/login` on the dashboard and nginx returned 405. Add
|
||||
/// the mount header to every canonical source block (HTTP and HTTPS snippet).
|
||||
fn heal_source_forwarded_prefix(content: &str) -> Option<String> {
|
||||
if !content.contains("proxy_pass http://127.0.0.2:8337/;") {
|
||||
return None;
|
||||
}
|
||||
let mut healed = content.to_owned();
|
||||
for indent in [" ", " "] {
|
||||
let old = format!(
|
||||
"proxy_pass http://127.0.0.2:8337/;\n{indent}proxy_http_version 1.1;\n{indent}proxy_set_header Host $http_host;\n{indent}proxy_set_header Cookie $http_cookie;\n{indent}proxy_set_header X-Real-IP $remote_addr;\n{indent}proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n{indent}proxy_set_header X-Forwarded-Proto $scheme;\n{indent}proxy_hide_header X-Frame-Options;"
|
||||
);
|
||||
let new = old.replace(
|
||||
&format!("\n{indent}proxy_hide_header X-Frame-Options;"),
|
||||
&format!(
|
||||
"\n{indent}proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n{indent}proxy_hide_header X-Frame-Options;"
|
||||
),
|
||||
);
|
||||
healed = healed.replace(&old, &new);
|
||||
}
|
||||
(healed != content).then_some(healed)
|
||||
}
|
||||
|
||||
fn heal_missing_nostr_signer(content: &str) -> Option<String> {
|
||||
if content.contains("location = /nostr-signer") {
|
||||
return None;
|
||||
}
|
||||
// The anchor occurs once in each complete HTTP/HTTPS dashboard server and
|
||||
// does not occur in the separate app-proxy snippet.
|
||||
let anchor = " location /aiui/ {";
|
||||
content
|
||||
.contains(anchor)
|
||||
.then(|| content.replace(anchor, &format!("{}{}", NGINX_NOSTR_SIGNER_BLOCK, anchor)))
|
||||
}
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
@@ -1610,6 +1675,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let missing_v6_https =
|
||||
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
|
||||
let stale_web_search = heal_stale_web_search_block(&content).is_some();
|
||||
let missing_source_proxy = heal_missing_source_proxy(&content).is_some();
|
||||
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
|
||||
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
@@ -1620,6 +1688,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !missing_v6_http
|
||||
&& !missing_v6_https
|
||||
&& !stale_web_search
|
||||
&& !missing_source_proxy
|
||||
&& !missing_source_prefix
|
||||
&& !missing_nostr_signer
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -1629,6 +1700,15 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
if let Some(p) = heal_stale_web_search_block(&patched) {
|
||||
patched = p;
|
||||
}
|
||||
if let Some(p) = heal_missing_source_proxy(&patched) {
|
||||
patched = p;
|
||||
}
|
||||
if let Some(p) = heal_source_forwarded_prefix(&patched) {
|
||||
patched = p;
|
||||
}
|
||||
if let Some(p) = heal_missing_nostr_signer(&patched) {
|
||||
patched = p;
|
||||
}
|
||||
|
||||
if missing_v6_http {
|
||||
patched = patched.replace(
|
||||
@@ -1796,6 +1876,17 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn doctor_service_uses_the_canonical_ota_script_path() {
|
||||
let expected = format!("ExecStart={} --local", DOCTOR_SH_PATH);
|
||||
assert!(DOCTOR_SERVICE.lines().any(|line| line == expected));
|
||||
assert_eq!(
|
||||
DOCTOR_SH_PATH,
|
||||
"/opt/archipelago/scripts/container-doctor.sh"
|
||||
);
|
||||
assert!(!DOCTOR_SERVICE.contains("/home/archipelago/archy/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
||||
let outcome = PodmanHealOutcome::Unhealthy;
|
||||
@@ -1817,6 +1908,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_proxy_uses_same_origin_through_authenticated_app_gate() {
|
||||
let main = "server {\n location /app/gitea/ {\n }\n}\nserver {\n location /app/gitea/ {\n }\n}";
|
||||
let healed = heal_missing_source_proxy(main).expect("source proxy must be added");
|
||||
assert_eq!(
|
||||
healed.matches("location /app/archipelago-source/").count(),
|
||||
2
|
||||
);
|
||||
assert!(healed.contains("proxy_pass http://127.0.0.2:8337/;"));
|
||||
assert!(healed.contains("proxy_set_header Cookie $http_cookie;"));
|
||||
assert!(healed.contains("proxy_set_header X-Forwarded-Prefix /app/archipelago-source;"));
|
||||
assert!(heal_missing_source_proxy(&healed).is_none());
|
||||
|
||||
let snippet = "location /app/gitea/ {\n}";
|
||||
let healed = heal_missing_source_proxy(snippet).expect("snippet must be patched");
|
||||
assert!(healed.starts_with("# GitWorkshop follows the dashboard origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_source_proxy_gets_the_forwarded_mount_once() {
|
||||
let stale = "location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_hide_header X-Frame-Options;\n}";
|
||||
let healed = heal_source_forwarded_prefix(stale).expect("mount header must be added");
|
||||
assert_eq!(
|
||||
healed
|
||||
.matches("X-Forwarded-Prefix /app/archipelago-source")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(heal_source_forwarded_prefix(&healed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_signer_is_added_to_each_dashboard_server_only_once() {
|
||||
let main =
|
||||
"server {\n location /aiui/ {\n }\n}\nserver {\n location /aiui/ {\n }\n}";
|
||||
let healed = heal_missing_nostr_signer(main).expect("signer route must be added");
|
||||
assert_eq!(healed.matches("location = /nostr-signer").count(), 2);
|
||||
assert!(healed.contains("frame-ancestors 'self' http://$host:* https://$host:*"));
|
||||
assert!(heal_missing_nostr_signer(&healed).is_none());
|
||||
assert!(heal_missing_nostr_signer("location /app/gitea/ {}\n").is_none());
|
||||
}
|
||||
|
||||
/// The exact ExecStart framework-pt shipped with must parse, and the
|
||||
/// rewrite must preserve its listen port and forward target.
|
||||
#[test]
|
||||
|
||||
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node may be installed, started,
|
||||
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
|
||||
/// pruning (verified against upstream `cuprated/src/config.rs` — the
|
||||
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
|
||||
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
|
||||
/// Monero chain simply does not fit and running it would fill the filesystem
|
||||
/// and take Archipelago down. 450 = chain + growth/headroom: allows
|
||||
/// 500 GB-class disks, refuses the 250 GB VPS class.
|
||||
///
|
||||
/// SINGLE SOURCE OF TRUTH — the RPC gates
|
||||
/// (`api::rpc::package::dependencies`) and the boot reconciler
|
||||
/// (`container::prod_orchestrator`) both read this; a drift between them
|
||||
/// would silently reopen the disk-fill failure the gate exists to close.
|
||||
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
|
||||
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
||||
//! | cuprate | archy-cuprate-ui | Monero node status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
||||
"cuprate" => CUPRATE_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] =
|
||||
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-cuprate-ui",
|
||||
image_base: "cuprate-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/cuprate-ui",
|
||||
"/home/archipelago/archy/docker/cuprate-ui",
|
||||
"/home/archipelago/Projects/archy/docker/cuprate-ui",
|
||||
],
|
||||
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
|
||||
// secret to inject. Cuprate's restricted RPC (the only thing this UI
|
||||
// proxies) is unauthenticated by design — Monero's safe-for-public
|
||||
// subset — so the nginx.conf is baked into the image.
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
@@ -869,6 +890,7 @@ mod tests {
|
||||
"mempool-electrs",
|
||||
"fedimint",
|
||||
"fedimintd",
|
||||
"cuprate",
|
||||
];
|
||||
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
||||
.iter()
|
||||
@@ -893,6 +915,7 @@ mod tests {
|
||||
names(&orphan_companions(&[])),
|
||||
vec![
|
||||
"archy-bitcoin-ui",
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
@@ -906,7 +929,10 @@ mod tests {
|
||||
// electrumx installed, fedimint and lnd not — yet all four companions
|
||||
// were running because the reconciler was fed the manifest list.
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
||||
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
|
||||
assert_eq!(
|
||||
names(&orphans),
|
||||
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -926,12 +952,18 @@ mod tests {
|
||||
#[test]
|
||||
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
||||
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
||||
assert_eq!(orphans.len(), 4);
|
||||
assert_eq!(orphans.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backend_installed_leaves_no_orphans() {
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
|
||||
let orphans = orphan_companions(&ids(&[
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"electrumx",
|
||||
"fedimint",
|
||||
"cuprate",
|
||||
]));
|
||||
assert!(
|
||||
names(&orphans).is_empty(),
|
||||
"unexpected orphans: {:?}",
|
||||
@@ -970,7 +1002,12 @@ mod tests {
|
||||
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
||||
assert_eq!(
|
||||
names(&due),
|
||||
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
vec![
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,6 +1061,7 @@ mod tests {
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("fedimint").len(), 1);
|
||||
assert_eq!(companions_for("fedimintd").len(), 1);
|
||||
assert_eq!(companions_for("cuprate").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
///
|
||||
/// This must match what enrollment actually names the hidden service dir
|
||||
/// with — both the install-time auto-enroll (`install.rs`) and the manual
|
||||
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
|
||||
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
|
||||
/// gets its own identity arm rather than folding into the "bitcoin" alias:
|
||||
/// aliasing it here without also canonicalizing the write side would point
|
||||
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
|
||||
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
|
||||
fn tor_service_name(app_id: &str) -> Option<&'static str> {
|
||||
match app_id {
|
||||
"archipelago" => Some("archipelago"),
|
||||
"bitcoin-core" => Some("bitcoin-core"),
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
|
||||
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
|
||||
"lnd" | "lnd-ui" => Some("lnd"),
|
||||
@@ -906,6 +916,28 @@ mod launch_url_port_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tor_service_name_tests {
|
||||
use super::tor_service_name;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
|
||||
// Regression: enrollment (install.rs, tor.create-service) writes
|
||||
// HiddenServiceDir/tor-hostnames entries using the raw package_id
|
||||
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
|
||||
// shared "bitcoin" name here would point reads at a directory
|
||||
// enrollment never creates.
|
||||
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
|
||||
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
|
||||
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
|
||||
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extract_lan_address_tests {
|
||||
use super::extract_lan_address;
|
||||
|
||||
@@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
@@ -182,7 +183,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
||||
|
||||
// Networking
|
||||
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
|
||||
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
||||
|
||||
_ => None,
|
||||
|
||||
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
|
||||
///
|
||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
|
||||
// value shared with the install/start/restart/update RPC gates so boot
|
||||
// reconcile can never resume below the line they refuse at.
|
||||
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
|
||||
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
|
||||
}
|
||||
|
||||
/// Apps expected to exist from first boot on every node — the ONLY apps the
|
||||
/// boot reconciler may install from nothing. Every other app needs
|
||||
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
// Same shape as the archival-bitcoin skip above: recorded BEFORE
|
||||
// ensure_running_with_mode, so the "absent" desired-state recovery
|
||||
// below can never fire on this reason and undo it.
|
||||
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
disk_gb,
|
||||
"cuprate needs a larger disk (no pruning support) — skipping start"
|
||||
);
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("cuprate-insufficient-disk".into()),
|
||||
);
|
||||
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
match self.ensure_running_with_mode(&lm, mode).await {
|
||||
// Desired-state recovery: the app has no container and was left
|
||||
// "absent" by boot reconcile, BUT it was running at the last
|
||||
@@ -3565,6 +3591,54 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Materialise IndeedHub's AES root before the generic generated-secret
|
||||
/// pass. Old installers injected one known value directly into the API and
|
||||
/// worker environments, so an upgrade with either consumer still present
|
||||
/// must persist that value before container drift can recreate them. With
|
||||
/// no existing consumer this is a fresh install and receives random bytes.
|
||||
async fn ensure_indeedhub_aes_master(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if manifest.app.id != "indeedhub-api" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(crate::container::secrets::INDEEDHUB_AES_SECRET_NAME);
|
||||
let preserve_legacy = if secret_path.exists() {
|
||||
// The secret helper validates the existing file and, critically,
|
||||
// refuses to replace a damaged encryption root.
|
||||
false
|
||||
} else {
|
||||
let consumers = [
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub-build_api_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
];
|
||||
self.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.context("detecting an existing IndeedHub encryption-key consumer")?
|
||||
.iter()
|
||||
.any(|container| {
|
||||
let name = container.name.trim_start_matches('/');
|
||||
consumers.contains(&name)
|
||||
})
|
||||
};
|
||||
|
||||
if crate::container::secrets::ensure_indeedhub_aes_master_secret(
|
||||
&self.secrets_dir,
|
||||
preserve_legacy,
|
||||
)? {
|
||||
tracing::info!(
|
||||
app = "indeedhub-api",
|
||||
path = %secret_path.display(),
|
||||
"Persisted the legacy IndeedHub encryption root for upgrade compatibility"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
// Idempotency guard: partitioning already ran on this instance.
|
||||
// Re-running would re-taint against an environment that no longer
|
||||
@@ -3573,6 +3647,11 @@ impl ProdContainerOrchestrator {
|
||||
if !manifest.app.container.secret_env_refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// IndeedHub's data-encryption root needs an upgrade-aware first pass:
|
||||
// generic generation alone would replace the fleet-wide legacy value
|
||||
// and make previously encrypted data unreadable.
|
||||
self.ensure_indeedhub_aes_master(manifest).await?;
|
||||
|
||||
// Materialise any manifest-declared generated secrets before they're
|
||||
// read below. This is the single chokepoint every install/reconcile
|
||||
// path funnels through, so an app's secrets exist by the time its
|
||||
@@ -5312,6 +5391,27 @@ app:
|
||||
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
||||
let m = pull_manifest("lnd-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
|
||||
let m = pull_manifest("cuprate-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
|
||||
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
|
||||
// has no pruning — boot reconcile must leave it down.
|
||||
assert!(requires_cuprate_disk("cuprate", 250));
|
||||
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
|
||||
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
|
||||
assert!(!requires_cuprate_disk("cuprate", 1000));
|
||||
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
|
||||
// doomed sync is worse than a node that stays down until it can
|
||||
// measure (same direction as the archival-bitcoin skip).
|
||||
assert!(requires_cuprate_disk("cuprate", 0));
|
||||
// Nothing else is gated here: bitcoin apps self-prune, everything
|
||||
// else is irrelevant to the Monero chain.
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5627,6 +5727,52 @@ app:
|
||||
"app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n"
|
||||
}
|
||||
|
||||
fn indeedhub_api_manifest_yaml() -> &'static str {
|
||||
"app:\n id: indeedhub-api\n name: IndeedHub API\n version: 1.0.0\n container:\n image: x:1\n generated_secrets:\n - name: indeedhub-aes-master\n kind: hex16\n secret_env:\n - key: AES_MASTER_SECRET\n secret_file: indeedhub-aes-master\n"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_indeedhub_consumer_gets_migration_compatible_root() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("indeedhub-api", ContainerState::Running);
|
||||
let mut orch = orch_with(rt).await;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
orch.set_secrets_dir(tmp.path().to_path_buf());
|
||||
|
||||
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
let resolved = manifest
|
||||
.app
|
||||
.container
|
||||
.secret_env_refs
|
||||
.iter()
|
||||
.find(|entry| entry.env_key == "AES_MASTER_SECRET")
|
||||
.unwrap();
|
||||
assert_eq!(resolved.value.len(), 32);
|
||||
assert!(tmp.path().join("indeedhub-aes-master").exists());
|
||||
assert!(
|
||||
crate::container::secrets::ensure_indeedhub_aes_master_secret(tmp.path(), true).is_ok(),
|
||||
"the migrated file remains valid and stable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_indeedhub_install_gets_random_root() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
orch.set_secrets_dir(tmp.path().to_path_buf());
|
||||
|
||||
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
let first = crate::container::secrets::indeedhub_aes_master_secret(tmp.path()).unwrap();
|
||||
|
||||
let other = tempfile::TempDir::new().unwrap();
|
||||
crate::container::secrets::ensure_indeedhub_aes_master_secret(other.path(), false).unwrap();
|
||||
let second = crate::container::secrets::indeedhub_aes_master_secret(other.path()).unwrap();
|
||||
assert_ne!(first, second, "fresh installs must receive per-node roots");
|
||||
}
|
||||
|
||||
/// FED-07. Rotating a compromised credential leaves the RUNNING container
|
||||
/// holding the old value, so the rotation must flag the app for recreate.
|
||||
/// Without the flag the drift check skips it as restart-sensitive and the
|
||||
|
||||
@@ -140,6 +140,79 @@ fn random_base64(bytes: usize) -> String {
|
||||
/// daemon read `fedimint-gateway-hash`).
|
||||
pub const GATEWAY_HASH_SECRET_NAME: &str = "fedimint-gateway-hash";
|
||||
|
||||
/// Canonical filename for IndeedHub's envelope-encryption root. API and media
|
||||
/// worker must receive the same stable value: changing it after data has been
|
||||
/// encrypted can make that data unreadable.
|
||||
pub const INDEEDHUB_AES_SECRET_NAME: &str = "indeedhub-aes-master";
|
||||
|
||||
/// The fleet-wide value used by the legacy IndeedHub installers. It remains
|
||||
/// here only for the one-way migration of an already-installed stack: those
|
||||
/// nodes must persist the value they have been using before the manifest
|
||||
/// starts reading it from a file. Fresh installs must never receive it.
|
||||
const KNOWN_LEGACY_INDEEDHUB_AES_MASTER: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Ensure IndeedHub has a stable encryption root.
|
||||
///
|
||||
/// `preserve_legacy` is true only when an API/worker container already exists,
|
||||
/// proving this is an upgrade from the installer that shipped the known legacy
|
||||
/// value. In that case we persist that value once so recreating the containers
|
||||
/// does not orphan encrypted data. A fresh installation gets 16 random bytes
|
||||
/// encoded as 32 hex characters.
|
||||
///
|
||||
/// Unlike ordinary generated credentials, an existing-but-empty or unreadable
|
||||
/// encryption root is never self-healed by rotation: replacement could destroy
|
||||
/// access to data, so this fails loudly and leaves the file untouched.
|
||||
/// Returns true only when the legacy migration value was written.
|
||||
pub fn ensure_indeedhub_aes_master_secret(
|
||||
secrets_dir: &Path,
|
||||
preserve_legacy: bool,
|
||||
) -> Result<bool> {
|
||||
fs::create_dir_all(secrets_dir)
|
||||
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
|
||||
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
|
||||
|
||||
if path.exists() {
|
||||
let value = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"reading IndeedHub encryption root {} (refusing to replace it)",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if value.trim().is_empty() {
|
||||
anyhow::bail!(
|
||||
"IndeedHub encryption root {} is empty; refusing to replace a potentially \
|
||||
data-bearing key",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if preserve_legacy {
|
||||
write_secret(&path, KNOWN_LEGACY_INDEEDHUB_AES_MASTER)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let spec = GeneratedSecret {
|
||||
name: INDEEDHUB_AES_SECRET_NAME.to_string(),
|
||||
kind: SecretGenKind::Hex16,
|
||||
};
|
||||
ensure_one(secrets_dir, &spec)?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Read the stable IndeedHub encryption root after it has been materialised.
|
||||
pub fn indeedhub_aes_master_secret(secrets_dir: &Path) -> Result<String> {
|
||||
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
|
||||
let value = fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading IndeedHub encryption root {}", path.display()))?;
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
anyhow::bail!("IndeedHub encryption root {} is empty", path.display());
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
|
||||
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
|
||||
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
|
||||
@@ -356,6 +429,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeedhub_fresh_installs_get_distinct_per_node_encryption_roots() {
|
||||
let dir_a = tempfile::tempdir().unwrap();
|
||||
let dir_b = tempfile::tempdir().unwrap();
|
||||
|
||||
assert!(!ensure_indeedhub_aes_master_secret(dir_a.path(), false).unwrap());
|
||||
assert!(!ensure_indeedhub_aes_master_secret(dir_b.path(), false).unwrap());
|
||||
let value_a = indeedhub_aes_master_secret(dir_a.path()).unwrap();
|
||||
let value_b = indeedhub_aes_master_secret(dir_b.path()).unwrap();
|
||||
|
||||
assert_eq!(value_a.len(), 32);
|
||||
assert!(value_a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_ne!(value_a, KNOWN_LEGACY_INDEEDHUB_AES_MASTER);
|
||||
assert_ne!(value_a, value_b, "fresh nodes must not share an AES root");
|
||||
let mode = std::fs::metadata(dir_a.path().join(INDEEDHUB_AES_SECRET_NAME))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeedhub_existing_install_persists_legacy_root_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
|
||||
assert_eq!(
|
||||
indeedhub_aes_master_secret(dir.path()).unwrap(),
|
||||
KNOWN_LEGACY_INDEEDHUB_AES_MASTER
|
||||
);
|
||||
assert!(
|
||||
!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap(),
|
||||
"a second migration pass must be a no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeedhub_existing_unique_root_is_never_rotated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_indeedhub_aes_master_secret(dir.path(), false).unwrap();
|
||||
let before = indeedhub_aes_master_secret(dir.path()).unwrap();
|
||||
|
||||
assert!(!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
|
||||
assert_eq!(before, indeedhub_aes_master_secret(dir.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeedhub_empty_root_fails_without_overwriting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(INDEEDHUB_AES_SECRET_NAME);
|
||||
std::fs::write(&path, "").unwrap();
|
||||
|
||||
let err = ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap_err();
|
||||
assert!(err.to_string().contains("refusing to replace"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -6,41 +6,7 @@
|
||||
//! 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,
|
||||
8188,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
|
||||
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 18091, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
|
||||
| "indeedhub-api" => StartupTier::DependentService,
|
||||
|
||||
// Tier 4: Frontend/UI
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
|
||||
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
|
||||
// Tier 3: Application layer (everything else)
|
||||
_ => StartupTier::Application,
|
||||
|
||||
@@ -413,6 +413,11 @@ async fn main() -> Result<()> {
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// Dashboard-only updates can replace the NIP-07 provider without
|
||||
// recreating a running IndeedHub container. Reconcile its injected copy on
|
||||
// every daemon start so tab signing never remains pinned to an old asset.
|
||||
tokio::spawn(api::rpc::patch_indeedhub_nostr_provider());
|
||||
|
||||
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||
// only — effective next reboot; never restarts the running service.
|
||||
|
||||
@@ -22,6 +22,46 @@ use crate::wallet::ecash;
|
||||
///
|
||||
/// Returns the total sats swept in (0 if there was nothing to do, including
|
||||
/// when no router is configured or it doesn't have TollGate installed).
|
||||
///
|
||||
/// # KNOWN BROKEN as of 2026-09-07 — do not "fix" by adding `--json` without
|
||||
/// reading the rest of this comment first.
|
||||
///
|
||||
/// Confirmed live against archy-x250-pa3, two stacked bugs in the upstream
|
||||
/// `tollgate` CLI, not in this function:
|
||||
///
|
||||
/// 1. **This call never actually drains anything.** `tollgate wallet drain
|
||||
/// cashu` (no flags — what this function runs) prints an interactive
|
||||
/// `Are you sure? (y/N)` confirmation and reads stdin for the answer.
|
||||
/// `Router::run` executes over SSH with no PTY and empty stdin, so it
|
||||
/// always reads EOF, defaults to "N", and prints "Operation cancelled." —
|
||||
/// **with exit code 0**. The `drain_code != 0` check below can never catch
|
||||
/// this, so every single tick silently falls through to "no `Token:`
|
||||
/// lines found" → `Ok(0)`. No error, no log line (even at `warn!`), just
|
||||
/// quiet total inaction, forever. This has presumably never swept a
|
||||
/// single sat on any node.
|
||||
///
|
||||
/// 2. **The obvious fix is worse.** `tollgate --json wallet drain cashu`
|
||||
/// *does* skip the confirmation prompt — but confirmed live: when the
|
||||
/// wallet's internal per-mint registry holds more than one entry for what
|
||||
/// is really the same mint (here: `https://mint.minibits.cash/Bitcoin` vs.
|
||||
/// a stale `.../Bitcoin/` — leftover from before the trailing-slash
|
||||
/// `mint_url` fix elsewhere in this codebase; `wallet.db` still had a
|
||||
/// proof/registry entry keyed under the old slashed URL even after
|
||||
/// `config.json` was corrected), the CLI appears to complete a real swap
|
||||
/// against the *good* entry — spending and irreversibly consuming the
|
||||
/// original proofs, per how Cashu swaps work — then hits the second,
|
||||
/// empty, stale-keyed entry, reports the whole command as
|
||||
/// `"success": false`, and **never prints or persists the resulting
|
||||
/// token anywhere** (checked every location its own "will be saved to a
|
||||
/// file" warning implies: `/etc/tollgate/ecash/`, `/root`, `/tmp`,
|
||||
/// nothing). Balance went from 50 sats to 0 across that one call. The
|
||||
/// funds are gone — there is no undo once a swap is submitted to the
|
||||
/// mint.
|
||||
///
|
||||
/// Do not wire `--json` into this function until upstream fixes partial
|
||||
/// per-mint failure handling in `drain cashu` to preserve/return whatever it
|
||||
/// already successfully drained. Until then, the current silent-no-op
|
||||
/// behavior, while useless, is at least safe.
|
||||
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
|
||||
let cfg = net_router::load_router_config(data_dir).await?;
|
||||
if !cfg.configured {
|
||||
|
||||
@@ -207,7 +207,15 @@ impl CashuToken {
|
||||
}
|
||||
|
||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||
///
|
||||
/// Trims surrounding whitespace first: a token can arrive with stray
|
||||
/// leading/trailing whitespace from a clipboard paste, or (confirmed
|
||||
/// live, 2026-09-08) from Minibits' own NIP-04 claim-DM content, which
|
||||
/// has a trailing space after the base64 — none of the base64 alphabets
|
||||
/// in `decode_token_base64` tolerate that, so an otherwise-valid token
|
||||
/// would hard-fail with "Invalid base64" instead of parsing.
|
||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||
let token_str = token_str.trim();
|
||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||
return Self::deserialize_v4(payload);
|
||||
}
|
||||
@@ -508,6 +516,45 @@ mod tests {
|
||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||
}
|
||||
|
||||
/// Regression guard (2026-09-08): a real Minibits claim DM decrypted to
|
||||
/// a cashuB token with a trailing space after the base64 payload, which
|
||||
/// made every base64 alphabet in `decode_token_base64` reject it as
|
||||
/// invalid — three real payments got stuck retrying forever with
|
||||
/// "Invalid base64 in cashuB token" until `deserialize` started
|
||||
/// trimming the whole string first. Whitespace can show up around a
|
||||
/// token from more than one source (clipboard paste included), so this
|
||||
/// covers cashuA too, and leading as well as trailing.
|
||||
#[test]
|
||||
fn deserialize_trims_stray_whitespace() {
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "http://127.0.0.1:8175".to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 8,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "abcdef1234567890".to_string(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.to_string(),
|
||||
}],
|
||||
}],
|
||||
memo: None,
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
let encoded = token.serialize().unwrap();
|
||||
assert!(encoded.starts_with("cashuA"));
|
||||
|
||||
for wrapped in [
|
||||
format!("{encoded} "),
|
||||
format!(" {encoded}"),
|
||||
format!(" {encoded}\n"),
|
||||
format!("{encoded}\t"),
|
||||
] {
|
||||
let decoded = CashuToken::deserialize(&wrapped)
|
||||
.unwrap_or_else(|e| panic!("failed on {wrapped:?}: {e}"));
|
||||
assert_eq!(decoded.total_amount(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_amount_multi_proof() {
|
||||
let token = CashuToken {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod minibits;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -137,6 +137,18 @@ impl EcashSeed {
|
||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
|
||||
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
|
||||
pub fn phrase(&self) -> String {
|
||||
self.mnemonic.to_string()
|
||||
}
|
||||
|
||||
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
|
||||
/// its `seedHash`, so the two wallets agree on wallet identity.
|
||||
pub fn seed_bytes(&self) -> [u8; 64] {
|
||||
self.seed
|
||||
}
|
||||
|
||||
pub fn source(&self) -> SeedSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
@@ -1746,11 +1746,6 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
|
||||
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
|
||||
// same reason router's mDNS/SSDP and every p2p port is exempt; each
|
||||
// carries its auth_rationale in the manifest.
|
||||
//
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
@@ -1776,7 +1771,7 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
30,
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
@@ -1811,27 +1806,12 @@ app:
|
||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||
// three own-login consoles brought onto the manifest platform:
|
||||
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
|
||||
// (tailnet login on the web console), adguardhome 3000 (AGH admin
|
||||
// accounts + first-run wizard). All enforce their own login, and an
|
||||
// operator can re-gate any of them from Settings → Access control.
|
||||
//
|
||||
// dojobay 8188, added for the Dojo Bay app: a public onion directory
|
||||
// that anonymous Tor visitors must be able to browse with no
|
||||
// dashboard login; its own Auth47 (BIP47 payment-code challenge)
|
||||
// gates listing management and the admin console.
|
||||
//
|
||||
// NOTE: as of this change, `left` also carries two entries this
|
||||
// assertion does not yet list — adguardhome at 3030 (not the 3000
|
||||
// hardcoded below) and cuprate at 18090 — both pre-existing drift
|
||||
// from before this change, not introduced by it. Left for whoever
|
||||
// owns those apps to reconcile; not touched here to keep this diff to
|
||||
// the dojobay addition.
|
||||
// (tailnet login on the web console). Both enforce their own login,
|
||||
// and an operator can re-gate either from Settings → Access control.
|
||||
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),
|
||||
|
||||
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
|
||||
pub min_steps: u32,
|
||||
/// Whether the TollGate service should be running and enabled at boot.
|
||||
pub enabled: bool,
|
||||
/// Operator's own Lightning address for the daemon's built-in payout
|
||||
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
|
||||
/// weight 0.79 in the upstream default). `None` leaves whatever is
|
||||
/// already on the router untouched — which, on a router whose TollGate
|
||||
/// wasn't provisioned through this project, is an unmodified upstream
|
||||
/// placeholder nobody actually controls (confirmed live against
|
||||
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
|
||||
pub payout_address: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TollGateConfig {
|
||||
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
|
||||
step_size_ms: 60_000,
|
||||
min_steps: 1,
|
||||
enabled: true,
|
||||
payout_address: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
|
||||
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
|
||||
/// effect on what the daemon advertises or accepts.
|
||||
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||
router.uci_apply(
|
||||
"tollgate",
|
||||
&[
|
||||
("tollgate.main", "tollgate"),
|
||||
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
|
||||
("tollgate.main.metric", "milliseconds"),
|
||||
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
|
||||
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
|
||||
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
|
||||
("tollgate.main.currency", "sat"),
|
||||
("tollgate.main.mint_url", &cfg.mint_url),
|
||||
],
|
||||
)?;
|
||||
let step_size = cfg.step_size_ms.to_string();
|
||||
let min_steps = cfg.min_steps.to_string();
|
||||
let price_sats = cfg.price_sats.to_string();
|
||||
|
||||
let mut pairs = vec![
|
||||
("tollgate.main", "tollgate"),
|
||||
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
|
||||
("tollgate.main.metric", "milliseconds"),
|
||||
("tollgate.main.step_size", step_size.as_str()),
|
||||
("tollgate.main.min_steps", min_steps.as_str()),
|
||||
("tollgate.main.price_per_step", price_sats.as_str()),
|
||||
("tollgate.main.currency", "sat"),
|
||||
("tollgate.main.mint_url", &cfg.mint_url),
|
||||
];
|
||||
// Status-display only (see doc comment above) — only written when the
|
||||
// caller actually supplied one, so a reconfigure that doesn't touch
|
||||
// payout leaves whatever's already there alone.
|
||||
if let Some(addr) = &cfg.payout_address {
|
||||
pairs.push(("tollgate.main.payout_address", addr));
|
||||
}
|
||||
router.uci_apply("tollgate", &pairs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -97,3 +114,155 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
|
||||
.context("upload /etc/tollgate/config.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the operator's own payout Lightning address in
|
||||
/// `/etc/tollgate/identities.json` — the "owner" entry under
|
||||
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
|
||||
/// the other entries there are revenue-share addresses for the upstream
|
||||
/// project's own maintainers and must never be touched by this function).
|
||||
///
|
||||
/// No-op when `payout_address` is `None` — the UI only sends one when the
|
||||
/// operator has actually filled the field in, so a reconfigure of price/mint
|
||||
/// alone never overwrites this. Merges into whatever identities.json already
|
||||
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
|
||||
/// the merchant's own private key and must survive untouched); creates an
|
||||
/// "owner" entry if none exists yet rather than erroring, since a router
|
||||
/// whose TollGate wasn't provisioned through this project may have any
|
||||
/// upstream-default shape here.
|
||||
///
|
||||
/// Must run before the daemon restart in `restart_services` — like
|
||||
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
|
||||
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
|
||||
let Some(address) = payout_address else {
|
||||
return Ok(());
|
||||
};
|
||||
validate_payout_address(address)?;
|
||||
|
||||
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
|
||||
let mut doc = parse_identities(&existing)?;
|
||||
|
||||
merge_payout_identity(&mut doc, address)?;
|
||||
|
||||
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
|
||||
router
|
||||
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
|
||||
.context("upload /etc/tollgate/identities.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_identities(existing: &str) -> Result<serde_json::Value> {
|
||||
serde_json::from_str(existing.trim()).context(
|
||||
"parse existing /etc/tollgate/identities.json; refusing to overwrite malformed identity data",
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject malformed values before provisioning changes anything on the
|
||||
/// router. A payout typo otherwise remains dormant until the threshold is
|
||||
/// reached, when the operator discovers that settlement cannot resolve.
|
||||
pub fn validate_payout_address(address: &str) -> Result<()> {
|
||||
let (name, domain) = address
|
||||
.split_once('@')
|
||||
.context("Lightning address must look like name@example.com")?;
|
||||
if name.is_empty()
|
||||
|| domain.is_empty()
|
||||
|| domain.contains('@')
|
||||
|| address.chars().any(char::is_whitespace)
|
||||
{
|
||||
anyhow::bail!("Lightning address must look like name@example.com");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_payout_identity(doc: &mut serde_json::Value, address: &str) -> Result<()> {
|
||||
let identities = doc
|
||||
.as_object_mut()
|
||||
.context("identities.json root is not a JSON object")?
|
||||
.entry("public_identities")
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
let identities = identities
|
||||
.as_array_mut()
|
||||
.context("identities.json public_identities is not an array")?;
|
||||
|
||||
match identities
|
||||
.iter_mut()
|
||||
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
|
||||
{
|
||||
Some(owner) => {
|
||||
owner["lightning_address"] = serde_json::json!(address);
|
||||
}
|
||||
None => {
|
||||
identities.push(serde_json::json!({
|
||||
"name": "owner",
|
||||
"pubkey": "not currently used",
|
||||
"lightning_address": address,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{merge_payout_identity, parse_identities, validate_payout_address};
|
||||
|
||||
#[test]
|
||||
fn payout_merge_changes_only_owner_address() {
|
||||
let mut doc = serde_json::json!({
|
||||
"config_version": "v0.0.1",
|
||||
"owned_identities": [{ "name": "merchant", "privatekey": "keep-secret" }],
|
||||
"public_identities": [
|
||||
{ "name": "owner", "pubkey": "not currently used", "lightning_address": "old@example.com" },
|
||||
{ "name": "upstream", "lightning_address": "keep@example.com" }
|
||||
]
|
||||
});
|
||||
let before_owned = doc["owned_identities"].clone();
|
||||
let before_other = doc["public_identities"][1].clone();
|
||||
|
||||
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
|
||||
|
||||
assert_eq!(doc["owned_identities"], before_owned);
|
||||
assert_eq!(doc["public_identities"][1], before_other);
|
||||
assert_eq!(
|
||||
doc["public_identities"][0]["lightning_address"],
|
||||
"operator@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payout_merge_can_create_missing_owner() {
|
||||
let mut doc = serde_json::json!({ "public_identities": [] });
|
||||
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
|
||||
assert_eq!(doc["public_identities"][0]["name"], "owner");
|
||||
assert_eq!(
|
||||
doc["public_identities"][0]["lightning_address"],
|
||||
"operator@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payout_address_validation_rejects_typographical_failures() {
|
||||
assert!(validate_payout_address("operator@example.com").is_ok());
|
||||
for invalid in [
|
||||
"",
|
||||
"operator",
|
||||
"@example.com",
|
||||
"operator@",
|
||||
"a@b@c",
|
||||
"a b@example.com",
|
||||
] {
|
||||
assert!(
|
||||
validate_payout_address(invalid).is_err(),
|
||||
"accepted {invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_identity_data_is_never_replaced() {
|
||||
let err = parse_identities("{ truncated").unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("refusing to overwrite malformed identity data"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
||||
|
||||
config::apply(router, config)?;
|
||||
wifi::provision_ssid(router, config)?;
|
||||
// Must come after provision_ssid (creates the `tollgate` network this
|
||||
// folds the upstream installer's own default AP onto) — see
|
||||
// regate_upstream_default_aps for why this is needed at all.
|
||||
wifi::regate_upstream_default_aps(router)
|
||||
.context("re-gate upstream tollgate-module-basic-go default AP(s)")?;
|
||||
// Must come after provision_ssid (which creates br-tollgate) and before
|
||||
// the daemon restart below — config.json is only read at startup.
|
||||
config::apply_daemon_config(router, config)
|
||||
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
|
||||
config::apply_payout_identity(router, config.payout_address.as_deref())
|
||||
.context("write /etc/tollgate/identities.json owner payout address")?;
|
||||
// Also must come after provision_ssid: points gatewayinterface at
|
||||
// br-tollgate, which provision_ssid is what creates.
|
||||
nodogsplash::configure(router, config)
|
||||
|
||||
@@ -118,6 +118,61 @@ fn provision_firewall(router: &Router) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fold the upstream `tollgate-module-basic-go` installer's own default
|
||||
/// AP(s) onto the gated `tollgate` network.
|
||||
///
|
||||
/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot
|
||||
/// scripts itself (no real package manager to trigger them on OpenWrt 25.x —
|
||||
/// see its doc comment). Those upstream scripts rebrand OpenWrt's
|
||||
/// factory-default wifi sections (`wireless.default_radioN`, present on
|
||||
/// every fresh install) to a `TollGate-<serial>` SSID, but only ever touch
|
||||
/// the SSID — they leave `network` at its original `lan` binding. Nothing
|
||||
/// else in this project's own provisioning (`provision_ssid` above) ever
|
||||
/// looks at those sections; it only manages the separate `wireless.tollgate`
|
||||
/// SSID it creates itself. Left alone, the result is two open SSIDs
|
||||
/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's
|
||||
/// (wide open on `lan`, with a direct route to whatever's plugged into the
|
||||
/// wired LAN port).
|
||||
///
|
||||
/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining
|
||||
/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and
|
||||
/// zero NoDogSplash involvement — free, unmetered internet, no captive
|
||||
/// portal, on the router's own admin network.
|
||||
///
|
||||
/// Must run after `provision_network` (needs the `tollgate` network/bridge
|
||||
/// to already exist) and before the network/wifi restart in
|
||||
/// `restart_services` picks the new binding up.
|
||||
pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
|
||||
let sections = router.run_ok(
|
||||
"uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u",
|
||||
)?;
|
||||
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let network_key = format!("{}.network", section);
|
||||
let current = router.uci_get(&network_key).unwrap_or_default();
|
||||
let ssid = router
|
||||
.uci_get(&format!("{}.ssid", section))
|
||||
.unwrap_or_default();
|
||||
// A failed/changed upstream first-boot script can leave a stock
|
||||
// default_radioN section in place. Moving that interface merely
|
||||
// because it is on LAN can seize the router's existing management AP.
|
||||
// Only the public APs the TollGate installer demonstrably rebranded
|
||||
// belong on the paid network.
|
||||
if should_regate_upstream_ap(¤t, &ssid) {
|
||||
info!(
|
||||
"[{}] Re-gating upstream default AP {} ({}) onto the tollgate network",
|
||||
router.host, section, ssid
|
||||
);
|
||||
router.uci_set(&network_key, "tollgate")?;
|
||||
}
|
||||
}
|
||||
router.uci_commit(Some("wireless"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_regate_upstream_ap(network: &str, ssid: &str) -> bool {
|
||||
network.trim() == "lan" && ssid.trim().starts_with("TollGate-")
|
||||
}
|
||||
|
||||
/// Return the first available wireless radio device name (e.g. "radio0").
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
let out =
|
||||
@@ -126,3 +181,19 @@ fn detect_radio(router: &Router) -> Result<String> {
|
||||
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
|
||||
Ok(radio)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_regate_upstream_ap;
|
||||
|
||||
#[test]
|
||||
fn regates_only_confirmed_upstream_tollgate_aps() {
|
||||
assert!(should_regate_upstream_ap("lan", "TollGate-3458"));
|
||||
assert!(should_regate_upstream_ap(" lan\n", " TollGate-A1B2 "));
|
||||
|
||||
assert!(!should_regate_upstream_ap("lan", "OpenWrt"));
|
||||
assert!(!should_regate_upstream_ap("lan", "Archipelago Admin"));
|
||||
assert!(!should_regate_upstream_ap("tollgate", "TollGate-3458"));
|
||||
assert!(!should_regate_upstream_ap("lan", "tollgate-3458"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
FROM docker.io/library/node:24-alpine AS build
|
||||
|
||||
# GitWorkshop has no release artifacts, so pin the audited source revision.
|
||||
# The fetch verifies that the exact requested commit was checked out before any
|
||||
# dependency or build command runs.
|
||||
ARG GITWORKSHOP_COMMIT=dc36db64f6a2cca29d109829eabaf0a49d4bf4da
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /src
|
||||
RUN git init \
|
||||
&& git remote add origin https://github.com/DanConwayDev/gitworkshop.git \
|
||||
&& git fetch --depth=1 origin "${GITWORKSHOP_COMMIT}" \
|
||||
&& git checkout --detach FETCH_HEAD \
|
||||
&& test "$(git rev-parse HEAD)" = "${GITWORKSHOP_COMMIT}"
|
||||
|
||||
COPY gitworkshop-archipelago.patch /tmp/gitworkshop-archipelago.patch
|
||||
RUN git apply --check /tmp/gitworkshop-archipelago.patch \
|
||||
&& git apply /tmp/gitworkshop-archipelago.patch
|
||||
COPY gitworkshop-dependencies.patch /tmp/gitworkshop-dependencies.patch
|
||||
RUN git apply --check /tmp/gitworkshop-dependencies.patch \
|
||||
&& git apply /tmp/gitworkshop-dependencies.patch
|
||||
RUN npm ci \
|
||||
&& npm audit --audit-level=moderate
|
||||
RUN APP_BASE_PATH=/app/archipelago-source/ \
|
||||
APP_RELEASE_VERSION="archipelago-${GITWORKSHOP_COMMIT}" \
|
||||
npm run build
|
||||
|
||||
FROM docker.io/library/nginx:1.27.4-alpine
|
||||
|
||||
COPY --from=build /src/dist/ /usr/share/nginx/html/
|
||||
COPY nginx-main.conf /etc/nginx/nginx.conf
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY UPSTREAM.md /usr/share/doc/archipelago-source/UPSTREAM.md
|
||||
|
||||
# Run both nginx master and workers as the packaged unprivileged user. Writable
|
||||
# runtime paths live on the manifest's small mode-1777 `/tmp` tmpfs, so the
|
||||
# container needs neither Linux capabilities nor a writable root filesystem.
|
||||
EXPOSE 8337
|
||||
ENTRYPOINT []
|
||||
USER nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# GitWorkshop upstream
|
||||
|
||||
This image packages the GitWorkshop NIP-34 web client from:
|
||||
|
||||
- Source: https://github.com/DanConwayDev/gitworkshop
|
||||
- Pinned commit: `dc36db64f6a2cca29d109829eabaf0a49d4bf4da`
|
||||
- Upstream project: https://gitworkshop.dev/
|
||||
- App icon: `public/icons/icon.svg` from the same pinned revision (the artwork
|
||||
is only inset onto Archipelago's standard icon safe area).
|
||||
|
||||
The Archipelago integration patch only makes the upstream Vite/React
|
||||
application work below Archipelago's `/app/archipelago-source/` mount, injects
|
||||
the existing consent-gated Archipelago NIP-07 provider, disables the
|
||||
development-only `localhost:4869` cache-relay probe, and removes two unreachable
|
||||
lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
|
||||
GRASP, repository browser, issue, pull-request, or review interfaces.
|
||||
|
||||
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
|
||||
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
|
||||
install reports zero npm advisories; its type-check, 152 unit tests, and
|
||||
Archipelago subpath production build pass. Keeping this mechanical security
|
||||
update separate makes both the upstream integration and future dependency
|
||||
refreshes auditable.
|
||||
|
||||
The pinned revision and current upstream `main` do not contain a license file,
|
||||
the package metadata declares no license, and GitHub reports no detected
|
||||
license. Archipelago's owner explicitly accepted the resulting redistribution
|
||||
risk on 2026-09-11. This is a project risk decision, not a claim that
|
||||
GitWorkshop is licensed or that downstream recipients receive rights from its
|
||||
copyright holders. An explicit upstream license remains the preferred,
|
||||
auditable resolution.
|
||||
@@ -0,0 +1,418 @@
|
||||
diff --git a/index.html b/index.html
|
||||
index 6894507..a917f5d 100644
|
||||
--- a/index.html
|
||||
+++ b/index.html
|
||||
@@ -14,7 +14,7 @@
|
||||
property="og:description"
|
||||
content="Decentralized GitHub alternative over Nostr"
|
||||
/>
|
||||
- <meta property="og:image" content="/og-image.png" />
|
||||
+ <meta property="og:image" content="%BASE_URL%og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta
|
||||
@@ -22,15 +22,19 @@
|
||||
content="%APP_NAME% — git collaboration without the platform"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
- <meta name="twitter:image" content="/og-image.png" />
|
||||
+ <meta name="twitter:image" content="%BASE_URL%og-image.png" />
|
||||
<meta
|
||||
http-equiv="content-security-policy"
|
||||
- content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
|
||||
+ content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' http: https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
|
||||
/>
|
||||
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
- <link rel="icon" type="image/png" href="/favicon.png" />
|
||||
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
- <link rel="manifest" href="/manifest.webmanifest" />
|
||||
+ <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
+ <link rel="icon" type="image/png" href="%BASE_URL%favicon.png" />
|
||||
+ <link rel="apple-touch-icon" href="%BASE_URL%icons/apple-touch-icon.png" />
|
||||
+ <link
|
||||
+ rel="manifest"
|
||||
+ href="/manifest.webmanifest"
|
||||
+ crossorigin="use-credentials"
|
||||
+ />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -41,7 +45,9 @@
|
||||
background: #16171e;
|
||||
}
|
||||
</style>
|
||||
- <script src="/theme-init.js"></script>
|
||||
+ <script src="%BASE_URL%theme-init.js"></script>
|
||||
+ <script src="%BASE_URL%archipelago-nostrdb-config.js"></script>
|
||||
+ <script data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
@@ -119,7 +125,7 @@
|
||||
<div id="splash-content">
|
||||
<img
|
||||
class="splash-logo"
|
||||
- src="/icons/icon-192x192.png"
|
||||
+ src="%BASE_URL%icons/icon-192x192.png"
|
||||
width="64"
|
||||
height="64"
|
||||
alt=""
|
||||
diff --git a/public/archipelago-nostrdb-config.js b/public/archipelago-nostrdb-config.js
|
||||
new file mode 100644
|
||||
index 0000000..8f598c4
|
||||
--- /dev/null
|
||||
+++ b/public/archipelago-nostrdb-config.js
|
||||
@@ -0,0 +1,7 @@
|
||||
+// window.nostrdb.js probes a developer-only relay at localhost:4869 unless
|
||||
+// configured before the app module graph loads. On an installed node that
|
||||
+// address means the user's own device, can never be the app's cache relay,
|
||||
+// and is correctly blocked by GitWorkshop's production CSP.
|
||||
+window.nostrdbConfig = Object.assign({}, window.nostrdbConfig || {}, {
|
||||
+ localRelays: [],
|
||||
+});
|
||||
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
|
||||
index 0f681e7..16dff02 100644
|
||||
--- a/src/AppRouter.tsx
|
||||
+++ b/src/AppRouter.tsx
|
||||
@@ -20,6 +20,8 @@ import { MaintainerAcceptanceMonitor } from "./components/MaintainerAcceptanceMo
|
||||
import { useRepoPath } from "./hooks/useRepoPath";
|
||||
import { REPO_KIND } from "./lib/nip34";
|
||||
import { getGitWorkshopPath } from "./lib/gitworkshopUrl";
|
||||
+import { useLoginActions } from "./hooks/useLoginActions";
|
||||
+import { accounts } from "./services/accounts";
|
||||
|
||||
/**
|
||||
* Handles public GitWorkshop links in native builds. This stays inside the
|
||||
@@ -365,9 +367,46 @@ function LegacyRedirect() {
|
||||
return <RepoLayout />;
|
||||
}
|
||||
|
||||
+/** Turn an eager node identity choice into GitWorkshop's extension login. */
|
||||
+function ArchipelagoIdentityLogin() {
|
||||
+ const login = useLoginActions();
|
||||
+ const loginRef = useRef(login.extension);
|
||||
+ const loginRunning = useRef(false);
|
||||
+ loginRef.current = login.extension;
|
||||
+
|
||||
+ useEffect(() => {
|
||||
+ const bridge = (
|
||||
+ window as Window & {
|
||||
+ archipelagoNostr?: {
|
||||
+ onIdentitySelected?: (
|
||||
+ callback: (identity: { nostr_pubkey: string }) => void,
|
||||
+ ) => () => void;
|
||||
+ };
|
||||
+ }
|
||||
+ ).archipelagoNostr;
|
||||
+ if (!bridge?.onIdentitySelected) return;
|
||||
+
|
||||
+ return bridge.onIdentitySelected(() => {
|
||||
+ if (accounts.getActive() || loginRunning.current) return;
|
||||
+ loginRunning.current = true;
|
||||
+ void loginRef
|
||||
+ .current()
|
||||
+ .catch((error) => {
|
||||
+ console.error("Archipelago automatic login failed:", error);
|
||||
+ })
|
||||
+ .finally(() => {
|
||||
+ loginRunning.current = false;
|
||||
+ });
|
||||
+ });
|
||||
+ }, []);
|
||||
+
|
||||
+ return null;
|
||||
+}
|
||||
+
|
||||
function AppRouter() {
|
||||
return (
|
||||
- <BrowserRouter>
|
||||
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
+ <ArchipelagoIdentityLogin />
|
||||
<NativeGitWorkshopLinks />
|
||||
<NativeAndroidBackButton />
|
||||
<ScrollToTop />
|
||||
diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx
|
||||
index 3eb76d2..22b079b 100644
|
||||
--- a/src/components/AppFooter.tsx
|
||||
+++ b/src/components/AppFooter.tsx
|
||||
@@ -62,7 +62,7 @@ export function AppFooter() {
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity w-fit"
|
||||
>
|
||||
<img
|
||||
- src="/icons/icon.svg"
|
||||
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
|
||||
alt="GitWorkshop"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
|
||||
index b31aa79..cec9d39 100644
|
||||
--- a/src/components/AppHeader.tsx
|
||||
+++ b/src/components/AppHeader.tsx
|
||||
@@ -155,7 +155,11 @@ export function AppHeader() {
|
||||
to="/"
|
||||
className="group transition-opacity hover:opacity-80 shrink-0"
|
||||
>
|
||||
- <img src="/icons/icon.svg" alt="GitWorkshop" className="h-8 w-8" />
|
||||
+ <img
|
||||
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
|
||||
+ alt="GitWorkshop"
|
||||
+ className="h-8 w-8"
|
||||
+ />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
diff --git a/src/main.tsx b/src/main.tsx
|
||||
index 1e4fead..ae3a045 100644
|
||||
--- a/src/main.tsx
|
||||
+++ b/src/main.tsx
|
||||
@@ -10,9 +10,11 @@ import "@fontsource-variable/inter";
|
||||
// itself, so subsequent loads are fully uncontrolled. Capacitor packages local
|
||||
// assets and does not use this web-deployment cleanup worker.
|
||||
if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
|
||||
- navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
- /* ignore — browser may block in certain envs */
|
||||
- });
|
||||
+ navigator.serviceWorker
|
||||
+ .register(`${import.meta.env.BASE_URL}sw.js`)
|
||||
+ .catch(() => {
|
||||
+ /* ignore — browser may block in certain envs */
|
||||
+ });
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
|
||||
index 18e3593..685c422 100644
|
||||
--- a/src/pages/NotFound.tsx
|
||||
+++ b/src/pages/NotFound.tsx
|
||||
@@ -28,7 +28,7 @@ const NotFound = () => {
|
||||
Oops! Page not found
|
||||
</p>
|
||||
<a
|
||||
- href="/"
|
||||
+ href={import.meta.env.BASE_URL}
|
||||
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Return to Home
|
||||
diff --git a/src/services/settings.ts b/src/services/settings.ts
|
||||
index 8f9a1a7..5438a72 100644
|
||||
--- a/src/services/settings.ts
|
||||
+++ b/src/services/settings.ts
|
||||
@@ -124,8 +124,6 @@ export const fallbackRelaysCustomised$ = isCustomised$(
|
||||
* These are used by the event loaders to find events more efficiently.
|
||||
*/
|
||||
export const DEFAULT_LOOKUP_RELAYS = normalizeRelayList([
|
||||
- "wss://purplepag.es",
|
||||
- "wss://index.hzrd149.com",
|
||||
"wss://indexer.coracle.social",
|
||||
]);
|
||||
|
||||
diff --git a/vite.config.ts b/vite.config.ts
|
||||
index 0534fb9..d94fc70 100644
|
||||
--- a/vite.config.ts
|
||||
+++ b/vite.config.ts
|
||||
@@ -39,37 +39,38 @@ function htmlAppNamePlugin(): Plugin {
|
||||
*/
|
||||
function manifestPlugin(): Plugin {
|
||||
const virtualId = "/manifest.webmanifest";
|
||||
+ const appBase = process.env.APP_BASE_PATH ?? "/";
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
name: "GitWorkshop.dev",
|
||||
short_name: "GitWorkshop",
|
||||
description: "Decentralized GitHub alternative over Nostr",
|
||||
- start_url: "/",
|
||||
+ start_url: appBase,
|
||||
display: "standalone",
|
||||
background_color: "#16171e",
|
||||
theme_color: "#16171e",
|
||||
categories: ["development", "productivity", "utilities"],
|
||||
icons: [
|
||||
{
|
||||
- src: "/icons/icon-192x192.png",
|
||||
+ src: `${appBase}icons/icon-192x192.png`,
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
- src: "/icons/icon-512x512.png",
|
||||
+ src: `${appBase}icons/icon-512x512.png`,
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
- src: "/icons/pwa-maskable-192x192.png",
|
||||
+ src: `${appBase}icons/pwa-maskable-192x192.png`,
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
- src: "/icons/pwa-maskable-512x512.png",
|
||||
+ src: `${appBase}icons/pwa-maskable-512x512.png`,
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
@@ -82,6 +83,12 @@ function manifestPlugin(): Plugin {
|
||||
|
||||
return {
|
||||
name: "manifest",
|
||||
+ transformIndexHtml(html) {
|
||||
+ return html.replace(
|
||||
+ 'href="/manifest.webmanifest"',
|
||||
+ `href="${appBase}manifest.webmanifest"`,
|
||||
+ );
|
||||
+ },
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === virtualId) {
|
||||
@@ -104,6 +111,10 @@ function manifestPlugin(): Plugin {
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(() => ({
|
||||
+ // Archipelago serves GitWorkshop behind the dashboard origin. Vite's base
|
||||
+ // controls emitted asset URLs while BrowserRouter consumes the same value
|
||||
+ // below, so repository routes remain valid below that mount point.
|
||||
+ base: process.env.APP_BASE_PATH ?? "/",
|
||||
define: {
|
||||
__APP_NAME__: JSON.stringify(name),
|
||||
__APP_RELEASE_VERSION__: JSON.stringify(
|
||||
diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx
|
||||
index f59a7d2..3168910 100644
|
||||
--- a/src/components/auth/AccountSwitcher.tsx
|
||||
+++ b/src/components/auth/AccountSwitcher.tsx
|
||||
@@ -46,7 +46,7 @@ function SignerTypeBadge({ account }: { account: IAccount }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Puzzle className="w-3 h-3" />
|
||||
- Extension
|
||||
+ Extension / Archipelago
|
||||
</span>
|
||||
);
|
||||
if (account instanceof NostrConnectAccount)
|
||||
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx
|
||||
index 11f6716..0bba6a2 100644
|
||||
--- a/src/components/auth/LoginDialog.tsx
|
||||
+++ b/src/components/auth/LoginDialog.tsx
|
||||
@@ -239,9 +239,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
|
||||
try {
|
||||
if (!("nostr" in window)) {
|
||||
throw new Error(
|
||||
- "Nostr extension not found. Please install a NIP-07 extension.",
|
||||
+ "No NIP-07 signer found. Open GitWorkshop through Archipelago or install a browser extension.",
|
||||
);
|
||||
}
|
||||
+ const archipelago = (
|
||||
+ window as Window & {
|
||||
+ archipelagoNostr?: { selectIdentity?: () => Promise<unknown> };
|
||||
+ }
|
||||
+ ).archipelagoNostr;
|
||||
+ if (archipelago?.selectIdentity) await archipelago.selectIdentity();
|
||||
await login.extension();
|
||||
onLogin();
|
||||
onClose();
|
||||
@@ -437,7 +443,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Puzzle className="w-4 h-4" />
|
||||
- {isLoading ? "Logging in..." : "Log in with Extension"}
|
||||
+ {isLoading
|
||||
+ ? "Logging in..."
|
||||
+ : "Log in with Extension / Archipelago"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
|
||||
index b8de377..7f72f31 100644
|
||||
--- a/src/pages/Dashboard.tsx
|
||||
+++ b/src/pages/Dashboard.tsx
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ChevronUp,
|
||||
Pin,
|
||||
Search,
|
||||
+ Globe2,
|
||||
} from "lucide-react";
|
||||
import { CreateRepoDialog } from "@/components/CreateRepoDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +37,7 @@ import { useUserActivity } from "@/hooks/useUserActivity";
|
||||
import { useUserRepositories } from "@/hooks/useUserRepositories";
|
||||
import { useUserFollowedRepos } from "@/hooks/useUserFollowedRepos";
|
||||
import { useUserPinnedCoords } from "@/hooks/useUserPinnedRepos";
|
||||
+import { useRepositorySearch } from "@/hooks/useRepositorySearch";
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { useUserProfileSubscription } from "@/hooks/useUserProfileSubscription";
|
||||
import { useUserPath } from "@/hooks/useUserPath";
|
||||
@@ -409,6 +411,63 @@ function FollowedReposPanel({ pubkey }: { pubkey: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
+// ---------------------------------------------------------------------------
|
||||
+// Recent repositories from the wider Nostr network
|
||||
+// ---------------------------------------------------------------------------
|
||||
+
|
||||
+function NetworkRepositoriesPanel() {
|
||||
+ const { repos, isLoading } = useRepositorySearch("");
|
||||
+ const recent = repos?.slice(0, 6);
|
||||
+
|
||||
+ return (
|
||||
+ <div className="h-fit">
|
||||
+ <div className="pb-3 flex items-center justify-between gap-3">
|
||||
+ <h3 className="text-base font-semibold flex items-center gap-2">
|
||||
+ <Globe2 className="h-4 w-4 text-muted-foreground" />
|
||||
+ Nostr network
|
||||
+ </h3>
|
||||
+ <Button
|
||||
+ variant="ghost"
|
||||
+ size="sm"
|
||||
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
+ asChild
|
||||
+ >
|
||||
+ <Link to="/search">
|
||||
+ Browse all
|
||||
+ <ArrowRight className="h-3 w-3 ml-1" />
|
||||
+ </Link>
|
||||
+ </Button>
|
||||
+ </div>
|
||||
+
|
||||
+ {recent === undefined || (isLoading && recent.length === 0) ? (
|
||||
+ <div className="space-y-1">
|
||||
+ {Array.from({ length: 5 }).map((_, i) => (
|
||||
+ <RepoRowSkeleton key={i} />
|
||||
+ ))}
|
||||
+ </div>
|
||||
+ ) : recent.length > 0 ? (
|
||||
+ <div className="space-y-0.5">
|
||||
+ {recent.map((repo) => (
|
||||
+ <RepoListItem
|
||||
+ key={`${repo.selectedMaintainer}:${repo.dTag}`}
|
||||
+ repo={repo}
|
||||
+ />
|
||||
+ ))}
|
||||
+ </div>
|
||||
+ ) : (
|
||||
+ <div className="py-6 text-center">
|
||||
+ <p className="text-sm text-muted-foreground">
|
||||
+ No network repositories available
|
||||
+ </p>
|
||||
+ <p className="text-xs text-muted-foreground/60 mt-1">
|
||||
+ Check the git index relay in Settings
|
||||
+ </p>
|
||||
+ </div>
|
||||
+ )}
|
||||
+ </div>
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded notifications panel (compact, inbox only, max 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -591,6 +650,8 @@ export function Dashboard() {
|
||||
<MyRepositoriesPanel pubkey={pubkey} />
|
||||
<Separator className="opacity-40" />
|
||||
<FollowedReposPanel pubkey={pubkey} />
|
||||
+ <Separator className="opacity-40" />
|
||||
+ <NetworkRepositoriesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
worker_processes auto;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr notice;
|
||||
|
||||
events {
|
||||
worker_connections 256;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
# Host networking is required for the loopback-only Archipelago RPC.
|
||||
# Keep nginx itself on loopback so the authenticated app gate owns every
|
||||
# externally reachable listener.
|
||||
listen 127.0.0.1:8337;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /app/archipelago-source/manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The normal dashboard proxy strips this prefix before forwarding, while
|
||||
# direct app-gate access preserves it. Supporting both keeps health/debug
|
||||
# access useful without making launch depend on any particular interface.
|
||||
location ^~ /app/archipelago-source/ {
|
||||
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>An error occurred.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>If you are the system administrator of this resource then you should check
|
||||
the error log for details.</p>
|
||||
<p><em>Faithfully yours, nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
|
||||
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
|
||||
# (the only upstream this UI proxies) is unauthenticated by design —
|
||||
# Monero's safe-for-public subset — so there is nothing to substitute at
|
||||
# start time and no rotation to follow.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 18091
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Cuprate Node - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
background-image: url('assets/img/bg-network.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
|
||||
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
|
||||
|
||||
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
|
||||
left, compact live status cards on the right. */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
|
||||
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
|
||||
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
|
||||
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
|
||||
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
|
||||
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
|
||||
.info-card-copy .sub { margin:0; font-size:.6875rem; }
|
||||
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
|
||||
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
|
||||
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
|
||||
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
|
||||
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
|
||||
[data-panel].tab-hidden { display:none; }
|
||||
@media (max-width:700px) {
|
||||
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
|
||||
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
|
||||
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
|
||||
.app-header-text { text-align:center; }
|
||||
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
|
||||
.app-header-actions > .info-card { width:100%; }
|
||||
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
|
||||
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
|
||||
}
|
||||
@media (min-width:701px) {
|
||||
.app-header { flex-wrap:nowrap; }
|
||||
.app-header-text { min-width:0; }
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
header h1 .accent { color: inherit; }
|
||||
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
padding: 0.35rem 0.8rem; border-radius: 999px;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
|
||||
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
|
||||
|
||||
.card-title {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.stat:last-child { border-bottom: none; }
|
||||
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
|
||||
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
|
||||
.stat .value.warn { color: #f7931a; }
|
||||
.stat .value.err { color: #ef4444; }
|
||||
.stat .value.ok { color: #22c55e; }
|
||||
|
||||
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
|
||||
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
|
||||
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
|
||||
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.notice {
|
||||
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.35);
|
||||
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
|
||||
.notice b { color: white; }
|
||||
|
||||
.endpoint {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
|
||||
|
||||
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-layer"></div>
|
||||
<div class="wrap">
|
||||
<header class="glass-card" style="padding:1.5rem;">
|
||||
<div class="app-header">
|
||||
<div class="app-header-id">
|
||||
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
|
||||
<div class="app-header-text">
|
||||
<h1><span class="accent">Cuprate</span> Monero Node</h1>
|
||||
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="info-card">
|
||||
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
|
||||
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="offlineNotice" class="notice error" style="display:none;">
|
||||
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
|
||||
Check the service status and try again shortly.
|
||||
</div>
|
||||
|
||||
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
|
||||
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
|
||||
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
|
||||
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
|
||||
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
|
||||
</nav>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="glass-card" data-panel="node">
|
||||
<div class="card-title">Blockchain Sync</div>
|
||||
<div class="height-hero">
|
||||
<span class="big" id="height">—</span>
|
||||
<span class="of" id="targetOf">of —</span>
|
||||
</div>
|
||||
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
|
||||
<div class="progress-label" id="syncLabel">Waiting for node…</div>
|
||||
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
|
||||
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
|
||||
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="peers">
|
||||
<div class="card-title">Peers & Traffic</div>
|
||||
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
|
||||
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
|
||||
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
|
||||
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
|
||||
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="insights">
|
||||
<div class="card-title">Chain & Disk</div>
|
||||
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
|
||||
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
|
||||
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="connect">
|
||||
<div class="card-title">Connect a Wallet</div>
|
||||
<div class="endpoint">
|
||||
<span id="walletEndpoint">—</span>
|
||||
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Restricted RPC — Monero's own safe-for-public subset, what Feather,
|
||||
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
|
||||
stays container-loopback only and is never published.
|
||||
</div>
|
||||
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
|
||||
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
|
||||
Data served from this node's restricted RPC, refreshed every 15 seconds.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RPC = 'cuprate-rpc/';
|
||||
const POLL_MS = 15000;
|
||||
|
||||
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes);
|
||||
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let v = n, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
const s = Number(secs);
|
||||
if (!Number.isFinite(s) || s < 0) return '—';
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function setStat(id, value, cls) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
|
||||
el.className = 'value' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
function setStatus(kind, text) {
|
||||
document.getElementById('statusText').textContent = text;
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
dot.className = 'dot ' + kind;
|
||||
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function callRpc(endpoint) {
|
||||
const response = await fetch(RPC + endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
function render(info, heightFallback) {
|
||||
const height = info.height ?? heightFallback ?? 0;
|
||||
// Monero's get_info returns target_height == 0 when the node is
|
||||
// FULLY SYNCED — the field is the height being caught up to, not
|
||||
// the chain tip, so `??` cannot substitute for the 0 case (a
|
||||
// synced node would sit forever at "Syncing — 0.00%"). Treat
|
||||
// 0/absent as "target is our own height" — the same sentinel
|
||||
// core/archipelago/src/electrs_status.rs branches on.
|
||||
const rawTarget = info.target_height ?? info.target ?? 0;
|
||||
const target = rawTarget > 0 ? rawTarget : height;
|
||||
document.getElementById('height').textContent = tabular(height);
|
||||
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
|
||||
|
||||
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
|
||||
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
|
||||
|
||||
const synced = target > 0 && height >= target;
|
||||
if (synced) {
|
||||
document.getElementById('syncLabel').textContent = 'Fully synced';
|
||||
setStatus('online', 'Synced');
|
||||
} else {
|
||||
const behind = Math.max(0, target - height);
|
||||
document.getElementById('syncLabel').textContent =
|
||||
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
|
||||
setStatus('syncing', 'Syncing');
|
||||
}
|
||||
|
||||
const nettype = info.mainnet ? 'Mainnet'
|
||||
: info.testnet ? 'Testnet'
|
||||
: info.stagenet ? 'Stagenet'
|
||||
: (info.net || info.nettype || '—');
|
||||
setStat('nettype', nettype);
|
||||
document.getElementById('headerNetwork').textContent = nettype;
|
||||
document.getElementById('headerHeight').textContent = tabular(height);
|
||||
|
||||
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
|
||||
const started = info.start_time ?? info.startup_time;
|
||||
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
|
||||
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
|
||||
|
||||
setStat('outConns', tabular(info.outgoing_connections_count));
|
||||
setStat('inConns', tabular(info.incoming_connections_count));
|
||||
setStat('rpcConns', tabular(info.rpc_connections_count));
|
||||
setStat('whitePeers', tabular(info.white_peerlist_size));
|
||||
setStat('grayPeers', tabular(info.grey_peerlist_size));
|
||||
setStat('txPool', tabular(info.tx_pool_size));
|
||||
const alt = Number(info.alt_blocks_count || 0);
|
||||
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
|
||||
|
||||
setStat('difficulty', tabular(info.difficulty));
|
||||
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
|
||||
const free = info.free_space;
|
||||
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
|
||||
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
|
||||
|
||||
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
|
||||
document.getElementById('walletEndpoint').textContent =
|
||||
`${window.location.hostname}:18090`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let height = null;
|
||||
try {
|
||||
const h = await callRpc('get_height');
|
||||
height = h.height;
|
||||
} catch { /* get_info below carries the real error */ }
|
||||
try {
|
||||
const info = await callRpc('get_info');
|
||||
render(info, height);
|
||||
} catch {
|
||||
setStatus('offline', 'Node offline');
|
||||
if (height !== null) document.getElementById('height').textContent = tabular(height);
|
||||
}
|
||||
}
|
||||
|
||||
function copyEndpoint(btn) {
|
||||
const text = document.getElementById('walletEndpoint').textContent;
|
||||
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* best effort */ }
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const tab = button.dataset.tab;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
|
||||
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
|
||||
});
|
||||
});
|
||||
|
||||
// Height alone answers even while get_info is warming up; if both
|
||||
// fail the offline card explains the disk gate as a likely cause.
|
||||
refresh();
|
||||
setInterval(refresh, POLL_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
|
||||
# This container is host-networked, so nginx binds the HOST's address
|
||||
# directly; a bare `listen` would expose the page on LAN, Tailscale and
|
||||
# the mesh with the app gate nowhere in front of it. Binding loopback lets
|
||||
# the daemon claim the external addresses and authenticate them;
|
||||
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
|
||||
listen 127.0.0.1:18091;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Session gate for the RPC proxy below. Internal: reachable only by
|
||||
# nginx's own auth_request subrequest, never by a client.
|
||||
location = /_session_check {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:5678/auth/session-check;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
}
|
||||
|
||||
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
|
||||
# — Monero's own safe-for-public subset, what remote-node wallets use).
|
||||
# It injects no credentials the caller lacks, but it is still session-
|
||||
# gated here so the whole companion behaves as one authenticated surface
|
||||
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
|
||||
# proxy: loopback reaches it without the gate's challenge).
|
||||
location /cuprate-rpc/ {
|
||||
# Preflight carries no cookies by design — answer it before the gate,
|
||||
# otherwise the browser reports an opaque CORS failure instead of a 401.
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
auth_request /_session_check;
|
||||
proxy_pass http://127.0.0.1:18090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Access-Control-Allow-Origin $scheme://$http_host always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Vary "Origin" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
|
||||
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
|
||||
# a rebuilt companion image must actually be seen by the browser, while the
|
||||
# ETag still saves the transfer when nothing changed.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,393 +0,0 @@
|
||||
/* 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.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
// 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
@@ -1,15 +0,0 @@
|
||||
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`
|
||||
@@ -1,59 +0,0 @@
|
||||
## For Dojo seekers
|
||||
|
||||
> **Don't delete your wallet without your passphrase**
|
||||
>
|
||||
> Your [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) or [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) passphrase is shown only once, when the wallet is created, and is separate from the PIN you use to open the app; the two are not linked. To switch the Dojo your wallet connects to you must delete and re-create the wallet, so confirm you have the correct passphrase first. The passphrase cannot be recovered, and you need both the 12-word seed phrase and the passphrase to restore a wallet. To check a passphrase, go to **Settings → Wallet → Check BIP39 Passphrase**.
|
||||
>
|
||||
> 🔴 No passphrase: do not delete the wallet. Send the funds to a wallet you control instead.
|
||||
>
|
||||
> 🟢 Passphrase and 12 words: you can safely delete the wallet to change device or connect to another Dojo.
|
||||
>
|
||||
> If you have the passphrase but not the 12 words, you can still open the wallet by decrypting the backup file with the passphrase. If you lose the Dojo connection and don't have the passphrase, export the XPUB to Sparrow for a watch-only wallet and sign offline from [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
### Who is responsible for the listed nodes?
|
||||
|
||||
Not The Dojo Bay: this site is a **directory only**. We do not operate the nodes listed here, we cannot guarantee their uptime, honesty or safety, and we accept no responsibility for them or for any loss of funds or privacy. Status and reliability figures come from automated checks and can be wrong or out of date. Treat every listing as untrusted: verify the pairing details, prefer self-hosting, and connect at your own risk.
|
||||
|
||||
### Are there privacy concerns for Dojo seekers?
|
||||
|
||||
Yes. When you pair with a Dojo you share your extended public key (XPUB), and the operator can use it to view your past, present and future transactions. Only connect to a Dojo you consider reputable and trustworthy, and prefer your own node whenever possible.
|
||||
|
||||
### How do I verify a listing?
|
||||
|
||||
Every listing here is signed, so there is always something to check. Start with the PayNym: confirm it belongs to someone whose reputation you can check, whether stated in a social-media bio, on their own site, or mentioned publicly, and look it up in the [PayNym.rs](http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion) directory to see its code. Then take the signed message from the listing to the [BIP47 Message Verifier](http://ab64uow264ohynkalvlyhdrduwwl75n4urvc2vrbo3xjd4jycygiirqd.onion/lab) and fill in the fields; a correct message returns "Message verified successfully". If verification fails there, use **Tools → Verify message** inside [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/).
|
||||
|
||||
What this proves is narrow and worth being precise about. It proves that whoever holds the key behind that payment code published these exact pairing details, so the onion address and API key you are about to use are the ones their operator put their name to and not something substituted afterwards. It does not prove they are honest, that the node is well run, or that the payment code belongs to the person you think it does. That last part is your job, and it is why the PayNym step comes first.
|
||||
|
||||
### Why doesn't the site verify the signatures for me?
|
||||
|
||||
Because a page that checks its own claims is asking to be trusted twice. If this instance were compromised it could show a green tick over a forged listing just as easily as a real one, so verification done here would be worth nothing at the exact moment you needed it. Doing it in your own wallet or in an independent verifier is the only version of the check that survives us being wrong or dishonest, so we make that as easy as we can and deliberately stop short of doing it for you.
|
||||
|
||||
### Where do I learn to run my own Dojo?
|
||||
|
||||
A Dojo can be installed several ways: [RoninDojo](https://ronindojo.io), a vanilla Dojo (instructions at [dojo-osp.org](https://dojo-osp.org)), or through the [Umbrel](https://apps.umbrel.com/app/samourai-server), [Nodl](https://nodl.eu) and [Start9](https://marketplace.start9.com) marketplaces. It runs on almost any Bitcoin node implementation, giving you full control of your [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) / [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/) backend. Treat any public Dojo as strictly temporary or for testing: once your own node is running, migrate your funds to fresh addresses managed by your instance to avoid reusing previously exposed public keys.
|
||||
|
||||
## For Dojo runners
|
||||
|
||||
### Are there privacy concerns for Dojo runners?
|
||||
|
||||
Not security concerns so much as exposure ones. By sharing a pairing payload you reveal your Dojo's onion address, which a malicious party could try to DDoS. You also risk a large number of wallets pairing to your Dojo, so size your hardware accordingly. Until API-key management is fully in place you cannot un-share your pairing details once published.
|
||||
|
||||
### What do I have to sign, and when?
|
||||
|
||||
Your pairing payload, at submission, and again whenever you change it. The signature covers the exact JSON you publish, so a new onion address or a rotated API key needs a new signature over the new details: the old one attests to what you are replacing and will be refused. Sign it with the same PayNym you sign in with, under **PayNym → Sign message** in [Samourai](https://web.archive.org/web/20240424023506/https://samouraiwallet.com/) or [Ashigaru](http://ashigaruprvm4u263aoj6wxnipc4jrhb2avjll4nnk255jkdmj2obqqd.onion/), and paste the whole block including its headers.
|
||||
|
||||
### Is there a minimum Dojo version?
|
||||
|
||||
Yes, 1.27.0, judged on the version your node reports when we probe it rather than the one written into your pairing payload. If your node reports older than that, upgrade it before submitting.
|
||||
|
||||
### Can I change the onion address if I'm being DDoSed?
|
||||
|
||||
Yes, but you will have to re-pair every connected wallet, and update the listing here with a signed payload covering the new address (see above). Until you do, the directory keeps publishing the old one and your listing will show as down.
|
||||
|
||||
### Can I see how many wallets are connected to my Dojo?
|
||||
|
||||
No, and that will not be possible.
|
||||
|
||||
### Can I cap the number if my hardware is limited?
|
||||
|
||||
It isn't really about connections but about tracking a very large number of addresses, and that limit is high even on lower-grade devices.
|
||||
@@ -1 +0,0 @@
|
||||
{ "generated_at": null, "interval_minutes": 10, "nodes": [] }
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"retention_days": 90,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"interval_minutes": 10,
|
||||
"window_checks": 72,
|
||||
"nodes": {}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"generated_at": null,
|
||||
"source": "https://paynym.rs/api/v1/nym",
|
||||
"mapping": {}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"nodes": []
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"commit": "archipelago-app",
|
||||
"built": null
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 907 B |
@@ -1,57 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
#!/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); });
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/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); });
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/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); });
|
||||
}
|
||||
@@ -1,806 +0,0 @@
|
||||
#!/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); });
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/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); });
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user