Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 | ||
|
|
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 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 | ||
|
|
d8320896c4 | ||
|
|
b87f1f0612 | ||
|
|
1ca002661b | ||
|
|
0d0e2e243a | ||
|
|
9c49b502e3 | ||
|
|
d68a013e35 | ||
|
|
1464b1b24d | ||
|
|
82001403b4 |
@@ -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()
|
||||
},
|
||||
|
||||
+99
-1
@@ -1,5 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.16-alpha (2026-09-15)
|
||||
|
||||
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
|
||||
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
|
||||
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
|
||||
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
|
||||
|
||||
## v1.8.15-alpha (2026-09-13)
|
||||
|
||||
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
|
||||
- Added regression coverage for Cuprate install and installed-state grouping.
|
||||
- Release validation was rerun on the corrected tree before OTA and ISO publication.
|
||||
|
||||
## 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.
|
||||
|
||||
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
|
||||
|
||||
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
|
||||
|
||||
## v1.8.10-alpha (2026-09-02)
|
||||
|
||||
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
||||
|
||||
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
|
||||
|
||||
## v1.8.9-alpha (2026-09-01)
|
||||
|
||||
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
|
||||
@@ -14,6 +110,8 @@
|
||||
|
||||
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
|
||||
|
||||
## v1.8.8-alpha (2026-09-01)
|
||||
|
||||
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
|
||||
@@ -34,7 +132,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
|
||||
|
||||
+39
-14
@@ -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",
|
||||
@@ -353,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
@@ -53,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
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -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
|
||||
+58
-21
@@ -36,17 +36,38 @@ 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
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 300Gi
|
||||
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
|
||||
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
|
||||
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
# 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
|
||||
@@ -82,17 +103,21 @@ app:
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
|
||||
# the gate still takes the port over (loopback pin, external binds,
|
||||
# fronts the Tor onion) but skips the dashboard login challenge, same
|
||||
# reasoning as electrumx's port — wallet clients (Feather,
|
||||
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
|
||||
# cannot complete a browser login or hold a session cookie. The daemon
|
||||
# still uses its canonical container port 18089, but Penpot already owns
|
||||
# host port 18089, so this maps the public host port to the free 18090
|
||||
# instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -103,11 +128,23 @@ app:
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - fast_sync: cuprated's own default is false, which performs full
|
||||
# cryptographic verification (ring signatures + RandomX PoW) on every
|
||||
# incoming block instead of trusting checkpointed history. Root-caused
|
||||
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
|
||||
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
|
||||
# block rate. Set explicitly rather than relying on the binary
|
||||
# default so fresh deploys don't silently regress into full-verify.
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
|
||||
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
|
||||
# forced constant eviction/flush (595GB/24h block I/O on a node just
|
||||
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
|
||||
# the healthy reference node, and
|
||||
# resources.memory_limit above raised in step to keep headroom above it.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
@@ -128,21 +165,21 @@ app:
|
||||
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
||||
# operator reading Cuprated.toml on disk should be able to see and
|
||||
# tune the log level directly instead of the file silently omitting
|
||||
# the whole [tracing] table (verified live on amishparadise
|
||||
# the whole [tracing] table (verified live on the affected node
|
||||
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
||||
# the level was only discoverable by running `cuprated
|
||||
# --generate-config` and diffing). file.level is set to "info", NOT
|
||||
# cuprated's own raw default of "debug" — matches the reference dev
|
||||
# config this app was built and tested against
|
||||
# (ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml,
|
||||
# verified 2026-09-01), which deliberately runs file logging quieter
|
||||
# config this app was built and tested against (verified 2026-09-01),
|
||||
# which deliberately runs file logging quieter
|
||||
# than the binary default. max_log_files similarly follows that
|
||||
# reference (14, not the binary default of 7).
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
fast_sync = true
|
||||
target_max_memory = 8589934592
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
|
||||
+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,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.9-alpha"
|
||||
version = "1.8.16-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.9-alpha"
|
||||
version = "1.8.16-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,
|
||||
|
||||
@@ -42,6 +42,21 @@ fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fee budget for a send, matching lncli's own default: the payment amount
|
||||
/// (100%). Zero-amount invoices take the payer-supplied amount; fixed invoices
|
||||
/// take the invoice's own amount. Falls back to a nominal 1,000 sats only when
|
||||
/// both are somehow absent — the limit must never be left at LND's zero
|
||||
/// default, which rejects every fee-carrying route as "no route".
|
||||
fn fee_limit_sats(amount_sats: Option<u64>, decoded_amt: i64) -> i64 {
|
||||
if let Some(amt) = amount_sats {
|
||||
return amt as i64;
|
||||
}
|
||||
if decoded_amt > 0 {
|
||||
return decoded_amt;
|
||||
}
|
||||
1_000
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
@@ -107,6 +122,14 @@ impl RpcHandler {
|
||||
// enough, and it makes grpc-gateway's response a single JSON value.
|
||||
"no_inflight_updates": true,
|
||||
"timeout_seconds": 120,
|
||||
// Router.SendPaymentV2 treats an ABSENT fee limit as ZERO — every
|
||||
// real route carries a routing fee, so the pathfinder rejects
|
||||
// them all and the wallet gets "No route to the recipient" on
|
||||
// every send (fleet-wide, 2026-09-01: the v1.8.9 switch to the v2
|
||||
// route shipped without this, and a manual lncli test that set
|
||||
// --fee_limit masked it). lncli's own default is the payment
|
||||
// amount (100%), which is what we send here.
|
||||
"fee_limit_sat": fee_limit_sats(amount_sats, decoded_amt),
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
@@ -550,4 +573,15 @@ mod tests {
|
||||
"Insufficient channel balance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fee_limit_never_falls_back_to_zero() {
|
||||
// SendPaymentV2 defaults an ABSENT fee limit to zero — which rejects
|
||||
// every fee-carrying route as "no route". The budget must always be
|
||||
// positive: the payer-supplied amount for zero-amount invoices, the
|
||||
// invoice's own amount otherwise.
|
||||
assert_eq!(fee_limit_sats(Some(20_000), 0), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 20_000), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 0), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\" =>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl RpcHandler {
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
.run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
@@ -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.
|
||||
{
|
||||
@@ -323,7 +330,7 @@ impl RpcHandler {
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
Ok(result) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
@@ -338,7 +345,11 @@ impl RpcHandler {
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
|
||||
pre_state.clone().unwrap_or(PackageState::Running)
|
||||
} else {
|
||||
PackageState::Running
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
impl RpcHandler {
|
||||
/// Update a package to the version pinned in image-versions.sh.
|
||||
/// Update a package to the freshly verified catalog target.
|
||||
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||
pub(in crate::api::rpc) async fn handle_package_update(
|
||||
&self,
|
||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// An Update click must not act on an hourly cache that predates the
|
||||
// button. Fetch and verify first; failure leaves running containers alone.
|
||||
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.context(
|
||||
"Cannot check the signed app catalog; update cancelled before changing containers",
|
||||
)?;
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
// Reload even when bytes did not change: a previous reload may have
|
||||
// failed after the cache was written, or another refresher wrote it.
|
||||
orch.reload_manifests()
|
||||
.await
|
||||
.context("Cannot load current app manifests; update cancelled")?;
|
||||
}
|
||||
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
@@ -42,6 +57,22 @@ impl RpcHandler {
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
let targets = pinned
|
||||
.as_ref()
|
||||
.map(|target| self.resolve_images_to_pull(package_id, target));
|
||||
if let Some(targets) = &targets {
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
if !update_targets_need_change(targets, &installed)? {
|
||||
install_log(&format!(
|
||||
"UPDATE SKIP: {} — target versions already installed",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
@@ -80,6 +111,12 @@ impl RpcHandler {
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
if let Some(targets) = &targets {
|
||||
verify_update_targets(
|
||||
targets,
|
||||
&inspect_update_images(package_id).await?,
|
||||
)?;
|
||||
}
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
@@ -133,7 +170,8 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
let images_to_pull =
|
||||
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
warn!(
|
||||
"Update {}: container {} exited after recreate",
|
||||
package_id, name
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
o.status.success() && state == "running",
|
||||
"Update {}: container {} is not running after recreate",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Update {}: cannot inspect recreated container {}",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -514,6 +559,98 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
package_id
|
||||
);
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.arg("inspect").args(&containers).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
.await
|
||||
.context("Timed out checking installed images")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Cannot inspect installed images; update cancelled"
|
||||
);
|
||||
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||
inspected
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let name = entry
|
||||
.get("Name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
|
||||
let image = entry
|
||||
.get("ImageName")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
|
||||
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn installed_image_for_target<'a>(
|
||||
app_id: &str,
|
||||
installed: &'a [(String, String)],
|
||||
) -> Option<&'a str> {
|
||||
installed
|
||||
.iter()
|
||||
.find(|(name, _)| {
|
||||
candidate_app_ids_for_container(name)
|
||||
.iter()
|
||||
.any(|id| id == app_id)
|
||||
})
|
||||
.map(|(_, image)| image.as_str())
|
||||
}
|
||||
|
||||
/// A successful recreate is not proof that it used the downloaded image.
|
||||
fn verify_update_targets(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
|
||||
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
image_versions::extract_version_from_image(target)
|
||||
== image_versions::extract_version_from_image(running)
|
||||
|| image_versions::compare_image_versions(target, running)
|
||||
== Some(std::cmp::Ordering::Equal),
|
||||
"Update {}: recreated container did not reach target version {}",
|
||||
app_id,
|
||||
image_versions::extract_version_from_image(target)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check every stack component, not just the version shown on its tile. A
|
||||
/// newer backend must still update when its frontend version is unchanged.
|
||||
/// A stale target for any component cancels before pulling or stopping anything.
|
||||
fn update_targets_need_change(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<bool> {
|
||||
use std::cmp::Ordering;
|
||||
let mut changed = false;
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed);
|
||||
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
|
||||
Some(Ordering::Less) => anyhow::bail!(
|
||||
"Catalog target for {} is older than the installed image; refusing downgrade",
|
||||
app_id
|
||||
),
|
||||
Some(Ordering::Equal) => {}
|
||||
Some(Ordering::Greater) | None => changed = true,
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
// A primary container already at its target does not mean its backend or
|
||||
// database is current. Route every mapped stack through the component flow.
|
||||
!image_versions::containers_for_stack(package_id).is_empty()
|
||||
|| matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
"mysql-mempool" => push("archy-mempool-db"),
|
||||
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
|
||||
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
|
||||
"fedimintd" => push("fedimint"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let stale = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&stale, &installed).is_err());
|
||||
let current = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &legacy).unwrap());
|
||||
let newer = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&newer, &installed).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_update_checks_backend_even_when_frontend_matches() {
|
||||
let installed = vec![
|
||||
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
|
||||
];
|
||||
let mut targets = vec![
|
||||
(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/team/web:3.3.1-archy1".into(),
|
||||
),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
|
||||
];
|
||||
assert!(update_targets_need_change(&targets, &installed).unwrap());
|
||||
targets[0].1 = "r.test/team/web:3.3.1".into();
|
||||
assert!(update_targets_need_change(&targets, &installed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_completion_requires_the_target_version_to_be_installed() {
|
||||
let targets = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let mut installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(verify_update_targets(&targets, &installed).is_err());
|
||||
assert!(verify_update_targets(&targets, &[]).is_err());
|
||||
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
|
||||
assert!(verify_update_targets(&targets, &installed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
for app in [
|
||||
"immich",
|
||||
"penpot",
|
||||
"indeedhub",
|
||||
"mempool",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -400,7 +400,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
return crate::container::image_versions::available_catalog_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
// Read a self-default assignment without evaluating shell code.
|
||||
let default_prefix = format!("${{{key}:-");
|
||||
let val = val
|
||||
.strip_prefix(&default_prefix)
|
||||
.and_then(|v| v.strip_suffix('}'))
|
||||
.unwrap_or(val);
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
@@ -146,6 +152,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 +189,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,
|
||||
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
|
||||
return None;
|
||||
}
|
||||
available_catalog_update_for_images(pinned, running_image)
|
||||
}
|
||||
|
||||
/// A signed catalog binds the image to an app id, so a publisher namespace
|
||||
/// migration must not hide a real upgrade. Baseline pins still require the
|
||||
/// same repository via `available_update_for_images` above.
|
||||
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let running_version = extract_version_from_image(running_image);
|
||||
if pinned_version == running_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pinned_repo = image_without_registry_or_tag(&pinned);
|
||||
let running_repo = image_without_registry_or_tag(running_image);
|
||||
if pinned_repo != running_repo {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Never advertise a LOWER version as an update.
|
||||
//
|
||||
// Everything upstream of here is a version claim that can go stale: the
|
||||
// signed catalog, a legacy catalog entry with no manifest, the
|
||||
// image-versions.sh baseline pin. When one lags behind what a node is
|
||||
// actually running, a bare `pinned != running` check turns that staleness
|
||||
// into an "Update" button that rolls the node BACKWARDS — and a rollback
|
||||
// to a version withdrawn for a vulnerability is precisely the case where
|
||||
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
|
||||
// 2.3.9 pin, and the UI offering "update" to the exploited release.
|
||||
//
|
||||
// Only suppress when both tags parse as comparable version numbers, so
|
||||
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
|
||||
// keep the previous behaviour rather than silently losing updates.
|
||||
if let (Some(p), Some(r)) = (
|
||||
parse_version_parts(&pinned_version),
|
||||
parse_version_parts(&running_version),
|
||||
if matches!(
|
||||
compare_image_versions(pinned, running_image),
|
||||
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||
) {
|
||||
if p < r {
|
||||
return None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(pinned_version)
|
||||
}
|
||||
|
||||
/// Compare explicit image tags, ignoring registry and namespace. `None` means
|
||||
/// unknown ordering (including floating tags), never permission to downgrade.
|
||||
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
|
||||
/// release, not a SemVer prerelease below it.
|
||||
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
|
||||
use std::cmp::Ordering;
|
||||
let target = extract_version_from_image(target);
|
||||
let running = extract_version_from_image(running);
|
||||
if is_floating_tag(&target) || is_floating_tag(&running) {
|
||||
return None;
|
||||
}
|
||||
let target = target.strip_prefix('v').unwrap_or(&target);
|
||||
let running = running.strip_prefix('v').unwrap_or(&running);
|
||||
if target == running {
|
||||
return Some(Ordering::Equal);
|
||||
}
|
||||
let mut target_core = parse_version_parts(target)?;
|
||||
let mut running_core = parse_version_parts(running)?;
|
||||
while target_core.last() == Some(&0) {
|
||||
target_core.pop();
|
||||
}
|
||||
while running_core.last() == Some(&0) {
|
||||
running_core.pop();
|
||||
}
|
||||
match target_core.cmp(&running_core) {
|
||||
Ordering::Equal => {
|
||||
fn patch_revision(tag: &str) -> Option<u64> {
|
||||
if let Some((base, revision)) = tag.rsplit_once("-archy") {
|
||||
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return revision.parse().ok();
|
||||
}
|
||||
}
|
||||
tag.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.')
|
||||
.then_some(0)
|
||||
}
|
||||
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
|
||||
}
|
||||
order => Some(order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric components of a version tag, for ordering comparisons only.
|
||||
///
|
||||
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
||||
@@ -423,6 +452,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
|
||||
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
|
||||
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
|
||||
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
|
||||
assert_eq!(available_update_for_images(upstream, patch1), None);
|
||||
assert_eq!(available_update_for_images(patch1, patch2), None);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch1, upstream),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch2, patch1),
|
||||
Some("v3.3.1-archy2".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
|
||||
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
|
||||
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(patched, old),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(
|
||||
patched,
|
||||
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(available_update_for_images(patched, old), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_version_spelling_does_not_offer_update() {
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
|
||||
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
|
||||
assert_eq!(
|
||||
images["MEMPOOL_WEB_IMAGE"],
|
||||
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
);
|
||||
assert_eq!(
|
||||
images["MEMPOOL_BACKEND_IMAGE"],
|
||||
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
||||
);
|
||||
assert!(images
|
||||
.values()
|
||||
.all(|v| !v.contains('$') && !v.contains('}')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_var_mapping() {
|
||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||
|
||||
@@ -131,6 +131,10 @@ const LND_STATE_DIRS: &[&str] = &[
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
@@ -872,6 +876,188 @@ fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
|
||||
Ok(hex::encode_upper(Sha256::digest(&der)))
|
||||
}
|
||||
|
||||
// ── Channel-peer watchdog ──────────────────────────────────────────────────
|
||||
|
||||
/// Every open channel's remote peer that is NOT currently connected.
|
||||
/// Pure over LND's REST JSON so the selection can be unit-tested.
|
||||
///
|
||||
/// `/v1/peers` uses `pub_key`; `/v1/channels` uses `remote_pubkey` — the
|
||||
/// asymmetry is LND's, not ours.
|
||||
fn select_reconnect_targets(
|
||||
channels: &serde_json::Value,
|
||||
peers: &serde_json::Value,
|
||||
) -> Vec<String> {
|
||||
let connected: std::collections::HashSet<&str> = peers
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| p.get("pub_key").and_then(|v| v.as_str()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut targets: Vec<String> = channels
|
||||
.get("channels")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|c| c.get("remote_pubkey").and_then(|v| v.as_str()))
|
||||
.filter(|pk| !connected.contains(pk))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
targets
|
||||
}
|
||||
|
||||
/// Reconnect peers of open channels that LND has not re-established on its
|
||||
/// own. Returns the number of peers reconnected this pass.
|
||||
///
|
||||
/// LND normally reconnects channel peers after a restart — but not reliably:
|
||||
/// when the restart outages are long or repeated (an app update, a node
|
||||
/// reboot, reconciler churn), the peer link can stay down for hours while
|
||||
/// BOTH endpoints keep flagging the channel `disabled` in the routing
|
||||
/// graph. The node itself looks perfectly healthy and every payment in
|
||||
/// either direction fails "no route to the recipient" — observed live on
|
||||
/// framework-pt (2026-09-01): its only channel sat disabled on both policy
|
||||
/// sides for ~17h after the LND 0.21.2 update, while the wallet showed
|
||||
/// plenty of outbound. The channel graph is desired state; this keeps it.
|
||||
///
|
||||
/// Quietly returns Ok(0) when LND is not installed or its wallet is locked —
|
||||
/// that is every node without LND, on every pass.
|
||||
///
|
||||
/// `last_attempt` throttles retries per peer (`min_retry`) so an unreachable
|
||||
/// peer is not hammered every pass; the caller owns the map so the pass
|
||||
/// itself stays stateless and testable.
|
||||
pub(crate) async fn reconnect_disconnected_channel_peers(
|
||||
last_attempt: &mut std::collections::HashMap<String, std::time::Instant>,
|
||||
min_retry: std::time::Duration,
|
||||
) -> Result<usize> {
|
||||
let Ok(macaroon) = read_file_as_root(LND_ADMIN_MACAROON).await else {
|
||||
return Ok(0); // LND not installed (or not initialized yet)
|
||||
};
|
||||
let macaroon_hex = hex::encode(macaroon);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client for the channel-peer watchdog")?;
|
||||
|
||||
let channels: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing channels for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND channel list")?;
|
||||
// A locked wallet answers 503 with an error body — it parses as JSON
|
||||
// with no "channels" key, which selects nothing. That is a quiet pass.
|
||||
let peers: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing peers for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND peer list")?;
|
||||
|
||||
let mut reconnected = 0usize;
|
||||
for pubkey in select_reconnect_targets(&channels, &peers) {
|
||||
if last_attempt
|
||||
.get(&pubkey)
|
||||
.is_some_and(|t| t.elapsed() < min_retry)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last_attempt.insert(pubkey.clone(), std::time::Instant::now());
|
||||
|
||||
// Where does the peer live? Its advertised addresses in the public
|
||||
// graph. A peer with none (fully private) cannot be dialed from here
|
||||
// — LND itself may still find it; we only log the gap once per pass.
|
||||
// Unknown to the public graph (or the graph query failed) — nothing
|
||||
// to dial on.
|
||||
let Ok(node) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/graph/node/{pubkey}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node) = node.json::<serde_json::Value>().await else {
|
||||
continue;
|
||||
};
|
||||
let addresses: Vec<String> = node
|
||||
.get("node")
|
||||
.and_then(|n| n.get("addresses"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.get("addr").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if addresses.is_empty() {
|
||||
tracing::warn!(
|
||||
peer = %pubkey,
|
||||
"LND channel peer is disconnected and advertises no address — cannot dial it; payments through this channel stay unroutable"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(port) = port.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"perm": false,
|
||||
"timeout": "15s",
|
||||
"addr": { "pubkey": pubkey, "host": host, "port": port },
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
reconnected += 1;
|
||||
tracing::info!(
|
||||
peer = %pubkey,
|
||||
addr = %addr,
|
||||
"reconnected a disconnected channel peer (channel was unroutable)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(resp) => {
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
// Already connected between our list call and now — success.
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, %msg, "channel-peer connect attempt failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, error = %e, "channel-peer connect attempt failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reconnected)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -985,4 +1171,35 @@ mod tests {
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_pick_disconnected_channel_peers_only() {
|
||||
// Shape captured from a live node: /v1/channels uses remote_pubkey,
|
||||
// /v1/peers uses pub_key, and an offline channel's peer is simply
|
||||
// absent from the peer list — that absence is the whole signal.
|
||||
let channels = serde_json::json!({
|
||||
"channels": [
|
||||
{ "remote_pubkey": "AAA", "active": true },
|
||||
{ "remote_pubkey": "BBB", "active": false },
|
||||
{ "remote_pubkey": "AAA" }
|
||||
]
|
||||
});
|
||||
let peers = serde_json::json!({ "peers": [ { "pub_key": "AAA" } ] });
|
||||
|
||||
let targets = select_reconnect_targets(&channels, &peers);
|
||||
assert_eq!(targets, vec!["BBB".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_empty_without_channels_or_peers() {
|
||||
// No LND wallet (503 error body), locked wallet, or an empty node:
|
||||
// selects nothing, quietly.
|
||||
let error_body = serde_json::json!({ "message": "locked" });
|
||||
assert!(select_reconnect_targets(&error_body, &serde_json::json!({})).is_empty());
|
||||
assert!(select_reconnect_targets(
|
||||
&serde_json::json!({ "channels": [] }),
|
||||
&serde_json::json!({ "peers": [] })
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -4588,6 +4667,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
let mut resolved = lm.manifest.clone();
|
||||
resolve_catalog_image(&mut resolved);
|
||||
if resolved.app.container.build.is_none() {
|
||||
if let Some(target) = &resolved.app.container.image {
|
||||
if let Ok(running) = self.runtime.get_container_status(&name).await {
|
||||
match crate::container::image_versions::compare_image_versions(
|
||||
target,
|
||||
&running.image,
|
||||
) {
|
||||
Some(std::cmp::Ordering::Less) => anyhow::bail!(
|
||||
"Refusing to downgrade {} from {} to {} during update",
|
||||
app_id,
|
||||
running.image,
|
||||
target
|
||||
),
|
||||
Some(std::cmp::Ordering::Equal) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(&lm).await
|
||||
@@ -4997,6 +5097,7 @@ mod tests {
|
||||
calls: StdMutex<Vec<String>>,
|
||||
/// container_name -> ContainerState. Absence = "doesn't exist".
|
||||
containers: StdMutex<HashMap<String, ContainerState>>,
|
||||
running_images: StdMutex<HashMap<String, String>>,
|
||||
/// container_name -> Podman health status.
|
||||
health: StdMutex<HashMap<String, String>>,
|
||||
/// image_ref -> present. Absence = "not present in local storage".
|
||||
@@ -5121,7 +5222,13 @@ mod tests {
|
||||
health,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: "test-image".to_string(),
|
||||
image: self
|
||||
.running_images
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "test-image".to_string()),
|
||||
created: "now".to_string(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
@@ -5312,6 +5419,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 +5755,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
|
||||
@@ -6625,6 +6799,41 @@ app:
|
||||
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
|
||||
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("update-regression", ContainerState::Running);
|
||||
rt.running_images.lock().unwrap().insert(
|
||||
"update-regression".into(),
|
||||
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
);
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(
|
||||
"update-regression",
|
||||
&format!("registry.test/new/mempool-frontend:{target}"),
|
||||
),
|
||||
PathBuf::from("/tmp/update-regression"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
orch.upgrade("update-regression").await.is_err(),
|
||||
should_error
|
||||
);
|
||||
assert!(
|
||||
!rt.calls()
|
||||
.iter()
|
||||
.any(|call| call.starts_with("stop_container:")
|
||||
|| call.starts_with("remove_container:")
|
||||
|| call.starts_with("pull_image:")
|
||||
|| call.starts_with("create_container:")),
|
||||
"{:?}",
|
||||
rt.calls()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_removes_and_reinstalls() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -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,7 +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, 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.
|
||||
|
||||
@@ -841,6 +841,37 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// LND channel-peer watchdog — every 2 minutes, reconnect the peers
|
||||
// of open channels that LND has not re-established on its own. LND's
|
||||
// reconnect logic gives up with a long backoff after repeated or
|
||||
// extended downtime (an app update, a reboot, reconciler churn), and
|
||||
// while the peer link is down BOTH endpoints keep the channel flagged
|
||||
// `disabled` in the routing graph — payments fail "no route" in both
|
||||
// directions while the node itself looks perfectly healthy. The
|
||||
// channel graph is desired state; this keeps it (framework-pt,
|
||||
// 2026-09-01: only channel unroutable ~17h after the 0.21.2 update).
|
||||
// No-ops quietly on nodes without LND. Per-peer retries are throttled
|
||||
// to 10 minutes so an unreachable peer is not hammered every pass.
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(120));
|
||||
let mut last_attempt: HashMap<String, Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::container::lnd::reconnect_disconnected_channel_peers(
|
||||
&mut last_attempt,
|
||||
Duration::from_secs(600),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(n, "LND channel-peer watchdog reconnected channel peers"),
|
||||
Err(e) => debug!("LND channel-peer watchdog (non-fatal): {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||
// configured seed anchors into the running fips daemon via
|
||||
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2159,20 +2159,25 @@ async fn apply_per_app_auto_updates(
|
||||
}
|
||||
}
|
||||
|
||||
/// After a catalog refresh that changed the cached bytes, rebuild the
|
||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
||||
/// effect now instead of at the next service restart.
|
||||
async fn reload_manifests_if_changed(
|
||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||
/// may have been written before a previous reload failed. Auto-updates only run
|
||||
/// when the catalog and the orchestrator's manifests are ready together.
|
||||
async fn reload_catalog_manifests(
|
||||
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||
) {
|
||||
if !refresh.changed {
|
||||
return;
|
||||
}
|
||||
let Some(orch) = orchestrator else { return };
|
||||
) -> bool {
|
||||
let Some(orch) = orchestrator else {
|
||||
return false;
|
||||
};
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
||||
Ok(n) => {
|
||||
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,7 +2193,9 @@ pub async fn run_update_scheduler(
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Ok(refresh) => {
|
||||
reload_catalog_manifests(refresh, &orchestrator).await;
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
@@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler(
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||
// catalog-shipped manifest fixes apply without a service restart.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
|
||||
}
|
||||
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Per-app auto-update-to-latest (multi-version support). Runs every tick
|
||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||
// may still show cached badges but must not trigger container changes.
|
||||
if catalog_ready {
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
|
||||
@@ -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,13 +1806,11 @@ 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.
|
||||
// (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),
|
||||
("gitea".to_string(), 3001u16),
|
||||
("nginx-proxy-manager".to_string(), 8081u16),
|
||||
|
||||
+19
-15
@@ -15,25 +15,32 @@ pub enum PkgManager {
|
||||
impl Router {
|
||||
/// Detect which package manager is available.
|
||||
///
|
||||
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
|
||||
/// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
|
||||
/// than a hardcoded `/usr/bin/<tool>` — official OpenWrt images don't all
|
||||
/// symlink `/bin` into `/usr/bin` (e.g. the `glinet_gl-mt3000` 24.10.2
|
||||
/// build keeps them as separate real directories with `opkg` living in
|
||||
/// `/bin`), so a fixed absolute path silently misses a perfectly normal
|
||||
/// install and reports "no package management" (archy-x250-pa3, 2026-09-05).
|
||||
///
|
||||
/// - If `opkg` is on PATH → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `apk` is on PATH → run `apk update` (switching repos to HTTP
|
||||
/// first to work around missing CA bundle on fresh images), then try
|
||||
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
|
||||
/// 25.x) → `ApkNative`.
|
||||
/// - Neither found → error.
|
||||
pub fn opkg_check(&self) -> Result<PkgManager> {
|
||||
let (_, code) = self.run("test -x /usr/bin/opkg")?;
|
||||
let (_, code) = self.run("command -v opkg >/dev/null 2>&1")?;
|
||||
if code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
|
||||
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
|
||||
let (_, apk_code) = self.run("command -v apk >/dev/null 2>&1")?;
|
||||
if apk_code == 0 {
|
||||
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
|
||||
// Fresh images ship without a CA bundle; switch repos to HTTP so
|
||||
// apk's wget can reach the package index without TLS verification.
|
||||
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
|
||||
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
|
||||
let (update_out, update_code) = self.run("apk update 2>&1")?;
|
||||
if update_code != 0 {
|
||||
anyhow::bail!(
|
||||
"apk update failed (exit {}) — router may have no internet access. \
|
||||
@@ -43,7 +50,7 @@ impl Router {
|
||||
);
|
||||
}
|
||||
// Try to install opkg (only available on some 25.x builds).
|
||||
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
|
||||
let (add_out, add_code) = self.run("apk add opkg 2>&1")?;
|
||||
if add_code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
@@ -62,7 +69,7 @@ impl Router {
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"opkg not found at /usr/bin/opkg — this router's firmware may not \
|
||||
"Neither opkg nor apk found on this router's $PATH — its firmware may not \
|
||||
support package management (TollGate requires a standard OpenWrt build)"
|
||||
);
|
||||
}
|
||||
@@ -70,31 +77,28 @@ impl Router {
|
||||
/// `opkg update` — refresh package lists.
|
||||
pub fn opkg_update(&self) -> Result<()> {
|
||||
info!("[{}] opkg update", self.host);
|
||||
self.run_ok("/usr/bin/opkg update")?;
|
||||
self.run_ok("opkg update")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a package, skipping if already installed.
|
||||
pub fn opkg_install(&self, package: &str) -> Result<()> {
|
||||
// Check if already installed to avoid unnecessary network traffic.
|
||||
let (_, code) = self.run(&format!(
|
||||
"/usr/bin/opkg list-installed | grep -q '^{} '",
|
||||
package
|
||||
))?;
|
||||
let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
|
||||
if code == 0 {
|
||||
info!("[{}] {} already installed", self.host, package);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("[{}] opkg install {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
|
||||
self.run_ok(&format!("opkg install {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a package.
|
||||
pub fn opkg_remove(&self, package: &str) -> Result<()> {
|
||||
info!("[{}] opkg remove {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
|
||||
self.run_ok(&format!("opkg remove {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@ impl Router {
|
||||
}
|
||||
|
||||
info!("[{}] apk add {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
|
||||
self.run_ok(&format!("apk add {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,53 @@ use crate::Router;
|
||||
/// The OpenWrt package name for the TollGate reference implementation.
|
||||
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string.
|
||||
/// Pinned upstream release. Was stuck on v0.2.0 (Oct 2025) until 2026-09-05 —
|
||||
/// nine releases behind. v0.5.0's changelog covers exactly the failure modes
|
||||
/// hit live against archy-x250-pa3: a mint with an empty/broken keyset used
|
||||
/// to crash-loop the daemon forever ("graceful degradation when Cashu mints
|
||||
/// fail" in v0.5.0), and the bundled captive-portal build had no CBOR support
|
||||
/// at all, so it could only decode legacy `cashuA` tokens — rejecting the
|
||||
/// `cashuB` (NUT-00 V4) tokens modern wallets like Minibits generate by
|
||||
/// default ("portal improvements" in v0.5.0 include a JS bundle update that
|
||||
/// should carry a current cashu-ts with V4 support). Bump this string to move
|
||||
/// both this crate's URLs and the version baked into the source comments.
|
||||
const TOLLGATE_VERSION: &str = "v0.5.0";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string, for the
|
||||
/// `.ipk` (ar-archive) package format.
|
||||
/// Used when the package is not in any configured feed.
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
|
||||
fn ipk_url(arch: &str) -> Option<&'static str> {
|
||||
match arch {
|
||||
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
|
||||
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
|
||||
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
|
||||
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
|
||||
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
|
||||
_ => None,
|
||||
}
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
|
||||
fn ipk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"mips_24kc" => "mips_24kc",
|
||||
"mipsel_24kc" => "mipsel_24kc",
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"aarch64_cortex-a72" => "aarch64_cortex-a72",
|
||||
"arm_cortex-a7" => "arm_cortex-a7",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.ipk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct-download URLs for the native Alpine-style `.apk` package format —
|
||||
/// only published for a subset of architectures as of v0.5.0. Where
|
||||
/// available this is strictly better than [`ipk_url`] on an apk-native
|
||||
/// (OpenWrt 25.x+) router: `apk add` installs it directly (dependency
|
||||
/// resolution, postinst, uci-defaults all handled by apk itself), instead of
|
||||
/// the manual `ar`/`tar` extraction dance `install_ipk` has to do to unpack
|
||||
/// an `.ipk` on a router with no `opkg`.
|
||||
fn apk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.apk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
|
||||
@@ -34,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
}
|
||||
|
||||
// Package not in any feed — download the .ipk directly.
|
||||
let arch = router
|
||||
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
|
||||
let arch = router.run_ok(
|
||||
"opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
|
||||
)?;
|
||||
let arch = arch.trim();
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
@@ -88,7 +124,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
". /etc/openwrt_release 2>/dev/null \
|
||||
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
|
||||
&& [ -n \"$a\" ] && echo \"$a\" \
|
||||
|| /usr/bin/apk --print-arch 2>/dev/null \
|
||||
|| apk --print-arch 2>/dev/null \
|
||||
|| uname -m",
|
||||
)?;
|
||||
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
|
||||
@@ -103,6 +139,38 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
anyhow::bail!("Could not determine router architecture");
|
||||
}
|
||||
|
||||
// Prefer a native .apk when the release publishes one for this arch —
|
||||
// `apk add` handles the install itself (deps, postinst, uci-defaults),
|
||||
// skipping the manual ar/tar extraction the .ipk fallback below needs.
|
||||
if let Some(url) = apk_url(arch) {
|
||||
info!(
|
||||
"[{}] Downloading native TollGate .apk for {} from GitHub releases",
|
||||
router.host, arch
|
||||
);
|
||||
let (dl_out, dl_code) = router.run(&format!(
|
||||
"wget --no-check-certificate -O /tmp/tollgate.apk '{}' 2>&1",
|
||||
url
|
||||
))?;
|
||||
if dl_code != 0 {
|
||||
anyhow::bail!("TollGate .apk download failed: {}", dl_out.trim());
|
||||
}
|
||||
let (size_out, _) = router.run("wc -c < /tmp/tollgate.apk 2>/dev/null")?;
|
||||
let size: u64 = size_out.trim().parse().unwrap_or(0);
|
||||
if size < 50_000 {
|
||||
anyhow::bail!(
|
||||
"Downloaded TollGate .apk is only {}B — wget likely captured an error page. \
|
||||
Check router internet access and that the release URL is reachable.",
|
||||
size
|
||||
);
|
||||
}
|
||||
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
||||
router.run_ok("rm -f /tmp/tollgate.apk")?;
|
||||
if add_code != 0 {
|
||||
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No pre-built TollGate package for architecture '{}'. \
|
||||
|
||||
@@ -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,14 +1,13 @@
|
||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||
#
|
||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
||||
# after any backend restart (podman reassigns the IP). See the script header.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
|
||||
# Keep the upstream startup logic; repair its rendered proxy configuration.
|
||||
# Publish this derived image under an Archipelago-specific tag, never the
|
||||
# upstream version tag that the registry mirror can overwrite.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
|
||||
FROM ${BASE}
|
||||
|
||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
||||
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||
# nginx -g "daemon off;") are inherited unchanged.
|
||||
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
|
||||
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
|
||||
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Mempool frontend DNS recovery
|
||||
|
||||
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
|
||||
start. Recreating the backend can change its Podman address while the frontend
|
||||
continues to serve its static page, leaving all API/WebSocket requests offline.
|
||||
|
||||
Build and test the derived image before publishing:
|
||||
|
||||
```sh
|
||||
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
|
||||
python3 scripts/test-mempool-dns-recovery.py
|
||||
```
|
||||
|
||||
The base is pinned by digest. The wrapper preserves upstream runtime options,
|
||||
then repairs all four local API/WebSocket routes after placeholder rendering.
|
||||
DNS is cached for five seconds using the container network resolver. Explicit
|
||||
rewrites preserve API prefixes and query arguments; backend absence does not
|
||||
prevent nginx startup. An unexpected upstream configuration fails startup
|
||||
instead of silently omitting the fix.
|
||||
|
||||
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
|
||||
mirror when updating mempool. Every upstream update must rebuild this wrapper
|
||||
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
|
||||
mapping, repeated repair, and frontend restart).
|
||||
|
||||
Publish the tested image before publishing the signed app catalog. Both the
|
||||
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
|
||||
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
|
||||
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
|
||||
Regular → Executable
+4
-136
@@ -1,137 +1,5 @@
|
||||
#!/bin/sh
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
CONF=/etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
# ─── archipelago patch ────────────────────────────────────────────────────
|
||||
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
|
||||
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
|
||||
# start and caches it for the process lifetime. Podman reassigns the backend
|
||||
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
|
||||
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
|
||||
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
|
||||
#
|
||||
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
|
||||
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
|
||||
# location→URI rewriting, each block is rewritten to preserve its original
|
||||
# path mapping exactly:
|
||||
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
|
||||
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
|
||||
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
|
||||
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
|
||||
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
|
||||
# Resolver address: podman's aardvark-dns answers on the network gateway
|
||||
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
|
||||
# works on any podman network/subnet (and still falls back for Docker).
|
||||
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
|
||||
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
|
||||
|
||||
if ! grep -q 'set \$mp_backend' "$CONF"; then
|
||||
awk -v res_addr="$ARCHY_RESOLVER" '
|
||||
BEGIN { res = 0 }
|
||||
/^[[:space:]]*location / && res == 0 {
|
||||
print "\tresolver " res_addr " valid=10s ipv6=off;"
|
||||
res = 1
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
|
||||
fi
|
||||
# ─── end archipelago patch ────────────────────────────────────────────────
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
|
||||
export LIGHTNING=true
|
||||
fi
|
||||
|
||||
# Runtime overrides - read env vars defined in docker compose
|
||||
|
||||
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
|
||||
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
|
||||
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
|
||||
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
|
||||
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
|
||||
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
|
||||
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
|
||||
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
|
||||
__NGINX_PORT__=${NGINX_PORT:=8999}
|
||||
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__BASE_MODULE__=${BASE_MODULE:=mempool}
|
||||
__ROOT_NETWORK__=${ROOT_NETWORK:=}
|
||||
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
|
||||
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
|
||||
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
|
||||
__LIGHTNING__=${LIGHTNING:=false}
|
||||
__AUDIT__=${AUDIT:=false}
|
||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__ACCELERATOR__=${ACCELERATOR:=false}
|
||||
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
|
||||
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
|
||||
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
|
||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
|
||||
|
||||
# Export as environment variables to be used by envsubst
|
||||
export __MAINNET_ENABLED__
|
||||
export __TESTNET_ENABLED__
|
||||
export __TESTNET4_ENABLED__
|
||||
export __SIGNET_ENABLED__
|
||||
export __LIQUID_ENABLED__
|
||||
export __LIQUID_TESTNET_ENABLED__
|
||||
export __ITEMS_PER_PAGE__
|
||||
export __KEEP_BLOCKS_AMOUNT__
|
||||
export __NGINX_PROTOCOL__
|
||||
export __NGINX_HOSTNAME__
|
||||
export __NGINX_PORT__
|
||||
export __BLOCK_WEIGHT_UNITS__
|
||||
export __MEMPOOL_BLOCKS_AMOUNT__
|
||||
export __BASE_MODULE__
|
||||
export __ROOT_NETWORK__
|
||||
export __MEMPOOL_WEBSITE_URL__
|
||||
export __LIQUID_WEBSITE_URL__
|
||||
export __MINING_DASHBOARD__
|
||||
export __LIGHTNING__
|
||||
export __AUDIT__
|
||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __ACCELERATOR__
|
||||
export __ACCELERATOR_BUTTON__
|
||||
export __SERVICES_API__
|
||||
export __PUBLIC_ACCELERATIONS__
|
||||
export __HISTORICAL_PRICE__
|
||||
export __ADDITIONAL_CURRENCIES__
|
||||
|
||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||
echo ${folder}
|
||||
envsubst < ${folder}/config.template.js > ${folder}/config.js
|
||||
|
||||
exec "$@"
|
||||
set -eu
|
||||
# Preserve the pinned upstream entrypoint (including new runtime options).
|
||||
# Apply our DNS repair only after it has rendered the nginx configuration.
|
||||
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# Resolve the backend again after container IP changes. Run after upstream
|
||||
# placeholder substitution, so the repair also works on an existing container.
|
||||
set -eu
|
||||
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
|
||||
resolv=${2:-/etc/resolv.conf}
|
||||
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
|
||||
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
|
||||
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
|
||||
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
|
||||
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
|
||||
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
|
||||
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
|
||||
|
||||
tmp=$(mktemp "${conf}.archy.XXXXXX")
|
||||
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
||||
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
|
||||
BEGIN {
|
||||
base = "http://" backend ":" port
|
||||
print "# Archipelago: refresh backend DNS after container replacement."
|
||||
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
|
||||
print "resolver_timeout 3s; # archy-dns"
|
||||
}
|
||||
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
|
||||
/^[[:space:]]*location[[:space:]]/ { location = $2 }
|
||||
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
|
||||
target = $2
|
||||
sub(/;$/, "", target)
|
||||
path = substr(target, length(base) + 1)
|
||||
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
|
||||
print "Unexpected backend location: " location > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
|
||||
print "Unexpected backend URI mapping" > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
# Explicitly preserve prefix substitution and query arguments. A variable
|
||||
# proxy_pass without a URI forwards the rewritten URI and original args.
|
||||
print "\t\tset $mp_backend " backend ";"
|
||||
if (path != location)
|
||||
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:" port ";"
|
||||
count++
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
|
||||
{ print }
|
||||
END {
|
||||
if (failed || count != 4) {
|
||||
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$conf" > "$tmp"
|
||||
cat "$tmp" > "$conf"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
/patch/repair-nginx.sh
|
||||
nginx -t
|
||||
exec "$@"
|
||||
@@ -0,0 +1,243 @@
|
||||
# Archipelago 1.8.12-alpha Release Plan
|
||||
|
||||
**Target date:** 2026-09-11
|
||||
**Scope:** OTA, signed app registry, canonical Nostr source publication,
|
||||
Companion 0.5.32 alpha, and an x86_64 installer ISO
|
||||
**Candidate base:** `f5c0ba85` plus the release-preparation changes documented
|
||||
here
|
||||
|
||||
## Release Outcome
|
||||
|
||||
This release is complete only when all of the following are true:
|
||||
|
||||
1. Existing nodes can install the signed OTA and retain AIUI, app data, app
|
||||
manifests, Nostr identities, Lightning state, and network configuration.
|
||||
2. A fresh ISO installation boots, reports `1.8.12-alpha`, and serves the same
|
||||
dashboard/runtime payload as the OTA.
|
||||
3. The signed app registry contains GitWorkshop, its embedded manifest, and the
|
||||
registry-owned Source promotion.
|
||||
4. Archipelago's canonical repository is announced by
|
||||
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg` as the
|
||||
stable identifier `archy`, is cloneable through `nostr://`, and opens in
|
||||
GitWorkshop from the Source promotion.
|
||||
5. Contributors can create an issue or proposal without gaining canonical
|
||||
merge, release-tag, catalog-signing, or OTA-signing authority.
|
||||
|
||||
Shipping GitWorkshop alone does not satisfy item 4. The client is the interface;
|
||||
the signed NIP-34 announcement and reachable Git object servers are the source
|
||||
publication.
|
||||
|
||||
## Audited Candidate State
|
||||
|
||||
### Ready
|
||||
|
||||
- The accumulated candidate is committed and the development node is healthy.
|
||||
- GitWorkshop is healthy behind the authenticated same-origin app gate.
|
||||
- The app works as a top-level Companion WebView and as a browser tab; the node
|
||||
signer remains consent-gated and identity-switchable.
|
||||
- The Source icon, description, banner, Popular/All Apps placement, and
|
||||
maintainer npub are present in the development catalog.
|
||||
- Payment receipts, Minibits claim persistence, update cancellation, app
|
||||
credential interstitials, Gitea limits, certificate guidance, and the pasta
|
||||
listener repair are included in the candidate.
|
||||
- `ngit 2.6.3` and `git-remote-nostr 2.6.3` are installed on the node.
|
||||
- The existing public Gitea source and Smart HTTP clone endpoints respond with
|
||||
HTTP 200.
|
||||
- The release-root signer exists locally as a binary, while its mnemonic
|
||||
remains correctly offline.
|
||||
- ISO dependencies (`xorriso`, `mksquashfs`, and QEMU) are installed.
|
||||
- The corrected release gate passed on 2026-09-11: 1,093 frontend tests,
|
||||
production UI build, registry candidate/trust checks, Rust check, 114 focused
|
||||
backend tests plus orchestration, and live dashboard/AIUI/RPC probes. Android
|
||||
lint and the mobile Chromium signer-handoff regression also pass.
|
||||
- GitWorkshop's refreshed dependency lock reports zero production or
|
||||
development npm advisories. Its type-check, all 152 unit tests, and exact
|
||||
Archipelago subpath production build pass with `fflate` 0.8.3, React Router
|
||||
7.18.3, and Vitest 5.0.0.
|
||||
- The locally served Companion candidate is 28,157,847 bytes with SHA-256
|
||||
`30e0b6257f17bb5e072941ccce6fde04b6c8ae59a49b45169e1920aec1b108cf`.
|
||||
- The Discover banner and automatic Companion prompt read that artifact's
|
||||
no-cache metadata, display 0.5.32 build 52, and remember dismissal per build;
|
||||
the ISO gate derives the same expected version from the Android project.
|
||||
- The owner accepted the risk of redistributing the pinned, unlicensed
|
||||
GitWorkshop revision on 2026-09-11. `NOTICE` and `UPSTREAM.md` disclose that
|
||||
decision without claiming an upstream license.
|
||||
|
||||
### Not Yet Complete
|
||||
|
||||
- This checkout is not logged into `ngit` and has no Nostr repository remote or
|
||||
cached kind `30617` announcement.
|
||||
- The production signed catalog still contains 61 apps but not
|
||||
`archipelago-source` or `storefront`; a freshly generated candidate contains
|
||||
62 apps, 61 embedded manifests, GitWorkshop 0.4.0, and the Source promotion.
|
||||
- The previous public Companion download was 0.5.28-debug. A 0.5.32-debug
|
||||
candidate has now been built with the same certificate and can update that
|
||||
alpha channel; the accepted 0.5.32-uat build remains a separate package.
|
||||
- The shared debug certificate is intentionally public and its private key is
|
||||
reachable in an existing public Git branch. It provides upgrade continuity,
|
||||
not production authenticity. A future production Companion must use the
|
||||
`com.archipelago.app` package with a privately held release key.
|
||||
- The repository is large: this checkout has about 5.4 GiB of packed objects,
|
||||
and the Gitea repository is about 5 GiB. Public GRASP replication must be
|
||||
allowed enough time and verified rather than assumed complete.
|
||||
- The provided secrets audit reports known false positives in documentation,
|
||||
path-variable names, and deliberate test keys. Before Nostr publication,
|
||||
record that review and ensure local Git remote credentials are rotated; local
|
||||
`.git/config` credentials are not tracked and must never be copied into an
|
||||
announcement or release log.
|
||||
|
||||
## Required Owner Inputs
|
||||
|
||||
These secrets must be entered interactively and must not be sent in chat,
|
||||
committed, placed in shell history, or stored on the node:
|
||||
|
||||
1. Access to the Nostr maintainer identity matching the npub above. Prefer an
|
||||
interactive NIP-46 connection through `ngit account connect`; do not pass an
|
||||
nsec on a command line.
|
||||
2. The 24-word Archipelago release-master mnemonic for three signing actions:
|
||||
the OTA manifest, the app catalog, and the ISO checksum document.
|
||||
3. A decision to publish 0.5.32 on the existing explicitly non-production
|
||||
`.debug` Companion channel, omit it, or pause for a secure production-app
|
||||
signing ceremony.
|
||||
|
||||
The shared Companion update certificate is not a production secret. It was
|
||||
temporarily recovered from the public branch, verified against the distributed
|
||||
0.5.28 APK, used to build the 0.5.32 alpha upgrade candidate, and removed from
|
||||
the working tree. Neither it nor the UAT keystore belongs in the release commit.
|
||||
|
||||
## Execution Order
|
||||
|
||||
### Gate A — Freeze And Production Companion
|
||||
|
||||
1. Verify the shared Companion certificate against the existing public APK
|
||||
before building anything. This is complete for the alpha candidate.
|
||||
2. Build the 0.5.32 shared-key alpha APK with v1, v2, and v3 signature schemes.
|
||||
3. Verify package ID, version code/name, signing digest, and install-as-upgrade
|
||||
behavior on the owner's phone.
|
||||
4. Stage the APK and its JSON version metadata in the dashboard payload.
|
||||
5. Run formatting, frontend type checking/unit tests/build, Android lint/build,
|
||||
focused backend tests, manifest validation, registry-candidate validation,
|
||||
secret review, and live node smoke probes.
|
||||
6. Deploy that exact build on this node and perform the short final UAT:
|
||||
GitWorkshop and IndeedHub first login, identity switching, Companion WebView,
|
||||
external tab, certificate install, payment receipt, update cancellation, and
|
||||
credential interstitial.
|
||||
|
||||
Stop on any failure. Do not sign or publish a workaround build.
|
||||
|
||||
### Gate B — Publish Archipelago Source Through Nostr
|
||||
|
||||
Run Nostr publication from a disposable clean clone so `ngit init` cannot
|
||||
repoint or rewrite the release checkout's remotes.
|
||||
|
||||
1. Confirm the clean clone matches the final candidate and contains `LICENSE`,
|
||||
`CONTRIBUTING.md`, `SECURITY.md`, and `NOTICE`.
|
||||
2. Connect `ngit` interactively to the canonical maintainer identity and verify
|
||||
`ngit account whoami` reports the expected npub.
|
||||
3. Publish identifier `archy`, title `Archipelago`, the public project
|
||||
description/homepage, the existing Gitea HTTPS clone URL, and multiple
|
||||
current public GRASP services. Use at least `relay.ngit.dev` and
|
||||
`gitnostr.com`; add an Archipelago-operated GRASP service later without
|
||||
changing the repository coordinate.
|
||||
4. Allow the complete repository push to finish. Do not treat a published
|
||||
announcement as proof that all Git objects arrived.
|
||||
5. Verify the announcement author, identifier, relays, clone URLs, default
|
||||
branch, and release tags with `ngit repo`.
|
||||
6. In a second empty directory, clone the printed `nostr://` URL and compare its
|
||||
`HEAD` tree to the candidate.
|
||||
7. Open
|
||||
`/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy`
|
||||
in the packaged GitWorkshop and confirm README/code browsing works.
|
||||
8. Publish a test issue/proposal from a non-maintainer identity, review it from
|
||||
the maintainer identity, and confirm it cannot change canonical refs without
|
||||
maintainer authorization.
|
||||
9. Confirm clone/browse still work with one GRASP server unavailable. A later
|
||||
infrastructure release must add an Archipelago-operated GRASP endpoint; the
|
||||
signed coordinate remains stable when servers change.
|
||||
|
||||
The Source promotion carries the deterministic npub/identifier deep-link. It
|
||||
must not be published to the registry until steps 5–8 pass.
|
||||
|
||||
### Gate C — Prepare And Publish OTA 1.8.12-alpha
|
||||
|
||||
1. Convert the top `Unreleased` changelog section into
|
||||
`v1.8.12-alpha (2026-09-11)` and synchronize What's New.
|
||||
2. Run `bash scripts/create-release.sh 1.8.12-alpha`. Enter the release mnemonic
|
||||
once when prompted and terminate input with Ctrl-D.
|
||||
3. Verify the new release commit, annotated tag, staged backend/frontend
|
||||
artifacts, signed pending manifest, embedded AIUI, APK version, and clean
|
||||
tree.
|
||||
4. Publish with
|
||||
`SKIP_ISO=1 scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
|
||||
The script uploads and hashes artifacts before pushing the fleet-visible
|
||||
manifest. Never push `main` manually around this ordering.
|
||||
5. Verify both local and public manifests, both asset hashes, public HTTPS
|
||||
download links, and update availability from this node.
|
||||
6. Apply the OTA to this node first, reboot if requested, and rerun live smoke
|
||||
probes before allowing the catalog publication.
|
||||
|
||||
### Gate D — Publish The Signed App Registry
|
||||
|
||||
This follows the OTA canary because GitWorkshop's manifest builds from
|
||||
`/opt/archipelago/docker/archipelago-source`, which older nodes do not have.
|
||||
|
||||
1. Generate `releases/app-catalog.json` from the final manifests.
|
||||
2. Require 62 entries, 61 embedded manifests, `archipelago-source` 0.4.0,
|
||||
registry-owned storefront metadata, and the canonical Source deep-link.
|
||||
3. Run strict metadata, manifest, and deployed-registry trust-floor checks.
|
||||
4. Sign the exact catalog bytes using `bash scripts/sign-catalog.sh` and verify
|
||||
the pinned release-root DID.
|
||||
5. Commit and push only the signed catalog after the OTA canary succeeds.
|
||||
6. Confirm `/api/app-catalog` verifies the signature and a fresh node can see,
|
||||
install, launch, stop, start, and uninstall GitWorkshop.
|
||||
|
||||
### Gate E — Build, Test, Sign, And Attach The ISO
|
||||
|
||||
1. From clean `main` with the live signed 1.8.12 manifest and tag, run
|
||||
`bash scripts/build-iso-release.sh`. Do not use `--skip-gates` or `--no-qemu`
|
||||
for the publishable candidate.
|
||||
2. Require the release harness, strict catalog check, full Rust test suite,
|
||||
version/artifact checks, ISO mount smoke test, and QEMU boot attempt.
|
||||
3. Record the ISO filename, byte size, SHA-256, build commit, and whether QEMU
|
||||
was conclusive. If QEMU is inconclusive, boot the ISO on the target test
|
||||
machine before publication.
|
||||
4. Sign the checksum document with
|
||||
`bash scripts/sign-iso-checksums.sh <iso>` and cryptographically verify it.
|
||||
5. Attach the ISO, plain checksum, and signed checksum JSON by rerunning
|
||||
`scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
|
||||
6. Verify Gitea's stored sizes and public HTTPS links, then download/hash the
|
||||
public checksum documents. Flash/boot/install on real hardware before
|
||||
calling the ISO generally available.
|
||||
|
||||
## Rollback Rules
|
||||
|
||||
- OTA: the live manifest remains 1.8.11-alpha until all 1.8.12 artifacts have
|
||||
uploaded and verified. If canary application fails, do not publish the new
|
||||
catalog; restore the previous manifest/artifacts through the established
|
||||
rollback path.
|
||||
- Registry: retain the previously signed catalog. Roll back by publishing a
|
||||
newly signed catalog that removes GitWorkshop or restores its prior entry;
|
||||
never edit signed JSON in place.
|
||||
- Nostr source: repository state is authoritative only when signed by the
|
||||
configured maintainer. Add replacement servers before removing failed ones,
|
||||
then republish and run `ngit sync`.
|
||||
- ISO: an ISO is not referenced by the OTA manifest. A failed ISO build/upload
|
||||
cannot block or corrupt an already verified OTA; do not advertise a partial
|
||||
attachment.
|
||||
- Companion: retain 0.5.28 and the verified alpha signer until 0.5.32 installs
|
||||
as an upgrade. Never replace the public APK with the differently signed UAT
|
||||
build. Do not describe the public debug-key channel as production-secure.
|
||||
|
||||
## Final Release Record
|
||||
|
||||
Before announcing completion, record in the release notes:
|
||||
|
||||
- release/tag/commit and signed manifest DID;
|
||||
- backend and frontend SHA-256 values;
|
||||
- signed catalog commit, app count, and verification result;
|
||||
- canonical `nostr://` URL, NIP-34 coordinate, GRASP/clone endpoints, and clone
|
||||
verification commit;
|
||||
- Companion package/version/signing digest and APK SHA-256;
|
||||
- ISO filename/size/SHA-256/signature and smoke/QEMU/real-hardware results;
|
||||
- owner UAT acceptance and the explicit GitWorkshop no-license/dependency-risk
|
||||
decisions.
|
||||
+1
-1
@@ -132,7 +132,7 @@ curl -s http://<node>/rpc/v1 -b jar.txt -H 'Content-Type: application/json' \
|
||||
|
||||
Login returns a `session` cookie. State-changing calls also need the `X-CSRF-Token` header. Exactly twelve read-only methods are CSRF-exempt, so for those the cookie alone is enough:
|
||||
|
||||
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-version` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
|
||||
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-hostname` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
|
||||
|
||||
Anything not on that list — including `bitcoin.getinfo` and `monitoring.current` — needs the CSRF header. If TOTP is enabled, follow the login with `auth.login.totp`.
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ The ISO redistributes a full Debian (trixie) system plus ~29 container image tar
|
||||
|
||||
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
|
||||
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
|
||||
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
|
||||
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
|
||||
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
|
||||
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
|
||||
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
|
||||
|
||||
@@ -10,6 +10,7 @@ disagree, the code wins and the doc is a bug.
|
||||
- [Talking to your node](COMMANDS.md) — the conversational command surface
|
||||
- [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup
|
||||
- [Troubleshooting](troubleshooting.md) — common problems and how to resolve them
|
||||
- [OpenWrt Gateway Setup](openwrt-gateway-setup.md) — pairing an OpenWrt router and provisioning TollGate pay-as-you-go WiFi
|
||||
- [Gamepad / Controller Navigation](GAMEPAD-NAV.md) — driving the UI from a controller
|
||||
- [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface
|
||||
|
||||
|
||||
+211
-29
@@ -31,9 +31,6 @@ app:
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- /app/start.sh
|
||||
derived_env:
|
||||
- key: PUBLIC_URL
|
||||
template: https://{{HOST_MDNS}}:8180
|
||||
secret_env:
|
||||
- key: APP_PASSWORD
|
||||
secret_file: my-app-password
|
||||
@@ -55,6 +52,8 @@ app:
|
||||
- host: 8180
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -125,13 +124,59 @@ app:
|
||||
| `app.environment` | Static `KEY=value` environment entries |
|
||||
| `app.health_check` | HTTP or TCP health check settings |
|
||||
| `app.devices` | Explicit device paths |
|
||||
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
|
||||
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
|
||||
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
||||
|
||||
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
|
||||
|
||||
### Iframe embedding — the rules
|
||||
|
||||
#### What Archipelago decides, and what the app must declare
|
||||
|
||||
Archipelago works out the reachable hostname and browser scheme at launch
|
||||
time. An app must not bake a LAN IP, Tailscale IP, FIPS address, `.local`
|
||||
name, or the dashboard's current `http`/`https` scheme into its UI URL.
|
||||
Declare the UI once in `interfaces.main`, put the matching port behind the
|
||||
app gate, and use relative URLs for the app's own assets and links.
|
||||
|
||||
| Concern | App author | Archipelago |
|
||||
|---|---|---|
|
||||
| UI location | Declare `interfaces.main.port`, `protocol`, and `path` | Uses the address through which this browser reached the node |
|
||||
| Exposure | Bind a `gated`/`open` port to `127.0.0.1` | Publishes it on supported LAN, Tailscale, FIPS, and Tor ingress |
|
||||
| HTTP/HTTPS | Serve the declared upstream protocol locally | Keeps HTTP pages on HTTP; on an HTTPS dashboard, gate-fronted app ports use HTTPS on the same port |
|
||||
| Embedded or top-level | Default to iframe; declare an exception when required | Chooses iframe, browser tab, or companion-native view from generated launch metadata |
|
||||
| Navigation | Use relative same-app URLs and normal absolute external URLs | Preserves the selected node address and routes external links out of the companion app view |
|
||||
|
||||
`interfaces.main.protocol` describes the service behind the gate. It does
|
||||
not tell application code to hard-code that scheme into browser links: the
|
||||
gate can terminate TLS in front of a locally plain-HTTP container.
|
||||
|
||||
There are two important limits:
|
||||
|
||||
- `auth: none` bypasses the gate, so Archipelago cannot add TLS or make that
|
||||
port safe to embed from an HTTPS dashboard. Use it for protocols, not
|
||||
ordinary web UIs.
|
||||
- Same-origin mounts such as `/app/archipelago-source/` are platform-owned
|
||||
integrations. A normal app cannot request an arbitrary dashboard path in
|
||||
its manifest; use `interfaces.main` and a gated port.
|
||||
|
||||
When the platform does provide one of those same-origin mounts, the nginx
|
||||
location must pass its exact mount as `X-Forwarded-Prefix` to the app gate:
|
||||
|
||||
```nginx
|
||||
location /app/example/ {
|
||||
proxy_pass http://127.0.0.2:8123/;
|
||||
proxy_set_header X-Forwarded-Prefix /app/example;
|
||||
}
|
||||
```
|
||||
|
||||
The trailing slash on `proxy_pass` strips the mount from the upstream request;
|
||||
the header lets the gate put it back into its login form, login-page assets,
|
||||
and successful redirect. Omitting it makes a fresh mobile-browser session post
|
||||
to the dashboard's root `/__archipelago-gate/login`, which is not an app-gate
|
||||
endpoint and will normally return 405. This header is host integration config,
|
||||
not app-controlled manifest metadata, and must be a fixed literal path.
|
||||
|
||||
The dashboard opens apps in an **embedded frame** (My Apps → app session) by
|
||||
default. Whether that works is decided by HTTP headers, not by wishes, so
|
||||
know the mechanics:
|
||||
@@ -145,7 +190,7 @@ know the mechanics:
|
||||
behind Archipelago's app gate the clickjacking threat those headers address
|
||||
is already handled — every proxied request is authenticated by the gate
|
||||
first.
|
||||
- Therefore **the gate neutralizes frame blocking on proxied responses**: it
|
||||
- Therefore **the gate neutralizes frame blocking on gate-fronted responses**: it
|
||||
removes `X-Frame-Options` and strips only the `frame-ancestors` directive
|
||||
from the app's CSP. The rest of the app's CSP (script-src, connect-src, …)
|
||||
passes through untouched — the gate never weakens the app's own content
|
||||
@@ -214,6 +259,37 @@ underscores. Supported interface types are `ui`, `api`, and `metrics`; only
|
||||
`type: ui` is treated as a launchable app surface. Supported protocols are
|
||||
`http` and `https`, and `path` must start with `/`.
|
||||
|
||||
### Browser, iframe, and companion launch modes
|
||||
|
||||
Launch behavior is generated from the manifest. Application code should not
|
||||
sniff for a particular node IP or companion user-agent.
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
launch:
|
||||
# Use only for OAuth/WebAuthn, JS frame-busting, or another top-level
|
||||
# browser requirement that the gate cannot repair.
|
||||
open_in_new_tab: false
|
||||
|
||||
# Keep a different, app-specific parent-frame integration alive in the
|
||||
# Android companion. Standard Archipelago NIP-07 no longer needs this.
|
||||
requires_host_frame: false
|
||||
```
|
||||
|
||||
- Desktop/PWA: iframeable apps stay in the dashboard.
|
||||
`open_in_new_tab: true` apps open in a browser tab.
|
||||
- Android companion: ordinary apps open in the native in-app browser with its
|
||||
own navigation controls. `requires_host_frame: true` apps stay in the
|
||||
dashboard iframe so `window.parent.postMessage` integrations remain alive.
|
||||
- Never set both flags. A top-level page cannot simultaneously require its
|
||||
parent frame.
|
||||
- Relative app paths are resolved against the active dashboard origin before
|
||||
a native launch, so the same package works through LAN, Tailscale, and FIPS.
|
||||
|
||||
Test all four relevant paths before submission: HTTP dashboard iframe, HTTPS
|
||||
dashboard iframe with the node CA installed, companion launch, and every
|
||||
external link or login redirect that leaves the app.
|
||||
|
||||
### Nostr Signer Bridge (NIP-07)
|
||||
|
||||
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
|
||||
@@ -221,43 +297,149 @@ events without managing their own keys. Archipelago injects a **NIP-07 provider*
|
||||
(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges
|
||||
to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.
|
||||
|
||||
**How injection works.** After install, the host copies `nostr-provider.js` into the
|
||||
app container and patches the app's web server so every page loads it and the app is
|
||||
iframe-embeddable. This is **best-effort** and depends on your server config exposing
|
||||
the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g.
|
||||
IndeeHub) your `nginx.conf` must satisfy this contract:
|
||||
**How injection works.** The dashboard owns the consent UI and postMessage
|
||||
host, and ships the canonical `nostr-provider.js`, but generic containers are
|
||||
not silently rewritten. Package the provider explicitly with a manifest
|
||||
`copy_from_host` hook (or bake the same provider into the image) and inject it
|
||||
into every HTML document your app serves. IndeeHub's manifest is the
|
||||
hook-based reference; Archipelago Source's outer same-origin nginx mount is a
|
||||
platform-owned reference.
|
||||
|
||||
1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host
|
||||
strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive
|
||||
CSP `frame-ancestors` will still block embedding.
|
||||
2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache
|
||||
`location = /nostr-provider.js` block is inserted immediately before it.
|
||||
3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A
|
||||
`sub_filter` that injects `<script src="/nostr-provider.js"></script>` before
|
||||
`</head>` is inserted right after it. (nginx must have `ngx_http_sub_module` —
|
||||
stock `nginx:alpine` does.)
|
||||
For an **nginx-served SPA**, use this contract:
|
||||
|
||||
1. **Be iframe-embeddable.** The app gate removes `X-Frame-Options` and only
|
||||
the CSP `frame-ancestors` directive from responses, but your own config
|
||||
should still express the intended embedded deployment rather than relying
|
||||
on repair.
|
||||
2. Serve `/nostr-provider.js` with `Cache-Control: no-cache, no-store`. Never
|
||||
precache the provider or the dashboard `/nostr-signer` navigation in an app
|
||||
service worker; signing protocol updates must reach existing installations.
|
||||
3. Inject a versioned provider URL such as
|
||||
`<script src="/nostr-provider.js?v=tab-signer-v4"></script>` before
|
||||
`</head>` in every SPA document. The token prevents an older iframe-only
|
||||
provider from surviving a dashboard update in the browser's asset cache.
|
||||
`sub_filter` is suitable when nginx has
|
||||
`ngx_http_sub_module` (stock `nginx:alpine` does).
|
||||
4. **If you proxy an API that does NIP-98 URL verification**, expose
|
||||
`proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the
|
||||
outer reverse proxy's prefix.
|
||||
|
||||
The patch is **idempotent** (it checks for an existing `nostr-provider` reference
|
||||
before editing) and re-runs on reinstall. If you rename or remove any of the anchor
|
||||
strings above, injection silently no-ops and `window.nostr` will be undefined in your
|
||||
app — so guard those lines in your config (see the contract comment block at the top of
|
||||
IndeeHub's `nginx.conf` for a template).
|
||||
Make the hook **idempotent** and fail its verification step if the provider is
|
||||
not present after install. A silent no-op leaves `window.nostr` undefined and
|
||||
is not release-ready.
|
||||
|
||||
> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either
|
||||
> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML;
|
||||
> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node.
|
||||
> Non-nginx servers (Next.js `node server.js`, etc.) should ship the provider
|
||||
> themselves and reference it in their HTML; the canonical host copy is
|
||||
> `/opt/archipelago/web-ui/nostr-provider.js`.
|
||||
|
||||
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
|
||||
Choose the launch mode for the app itself; the signer works in either shape:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: false # default; set true only if the app cannot be iframed
|
||||
open_in_new_tab: false
|
||||
requires_host_frame: false
|
||||
```
|
||||
|
||||
The provider supports both launch shapes. In a dashboard iframe it talks to
|
||||
the dashboard parent directly. In a browser tab or the companion's standalone
|
||||
WebView it creates a dashboard-origin signer frame, which renders the same
|
||||
identity chooser and consent card over the app and relays NIP-07 requests to
|
||||
the authenticated node session. It deliberately does not depend on
|
||||
`window.opener`, so `noopener` tab launches remain safe and functional.
|
||||
The app gate's successful login supplies the host-wide session and CSRF cookie
|
||||
pair in a fresh external browser; the signer broker validates that session
|
||||
directly and does not require the browser to have visited or logged into the
|
||||
dashboard first. Existing session-only browser tabs are repaired on their next
|
||||
gate-fronted app response. Do not add a second dashboard-login prerequisite in
|
||||
application code.
|
||||
|
||||
For that reason a NIP-07 app does **not** need `requires_host_frame: true`.
|
||||
Use the flag only if the app has some other parent-frame protocol. If a
|
||||
top-level app sends its own `Content-Security-Policy`, its `frame-src` must
|
||||
permit the dashboard origin; apps intended to work over every node address can
|
||||
allow `http:` and `https:` while relying on the provider's strict same-host
|
||||
parent validation. A policy limited to `frame-src 'self'` will block the
|
||||
broker when the app is running on a different port.
|
||||
|
||||
**Consent UI belongs to the platform.** Do not build a second signer modal,
|
||||
request a top-level window, or overlay the entire dashboard. A standard NIP-07
|
||||
call pauses while Archipelago shows its contained consent card inside the
|
||||
active app surface. After approval, the shared Nostr identity ring provides a
|
||||
short signing loader and completion state. The same host-owned flow renders in
|
||||
desktop browsers, installed PWAs, and the Android companion WebView.
|
||||
Silent background requests and remembered approvals deliberately keep the
|
||||
broker frame hidden; only an identity choice or an actual consent prompt may
|
||||
reveal it. If an app performs NIP-98 bootstrap and then navigates, it must wait
|
||||
for the provider Promise to finish rather than independently reloading while
|
||||
the consent result is still visible. The canonical provider coordinates its
|
||||
automatic IndeeHub-style session reload with the broker's hide notification.
|
||||
For top-level apps, that broker document must remain transparent. When hidden,
|
||||
its iframe must stay loaded but be reduced to a non-interactive 1px surface and
|
||||
parked physically off-screen. Removing/display-hiding the full-viewport iframe,
|
||||
or leaving it full-size with only `visibility:hidden`, can make Android WebView
|
||||
and mobile Chromium retain its last black/grey compositor surface above a
|
||||
healthy app until refresh. Keeping one parked broker also prevents a visible
|
||||
hide/recreate flash between `getPublicKey` and `signEvent`. The canonical
|
||||
provider owns this lifecycle; apps must not copy or manipulate its iframe.
|
||||
|
||||
Apps should treat the NIP-07 Promise as an ordinary asynchronous operation:
|
||||
disable only the initiating control, preserve the user's draft, handle a user
|
||||
denial as a normal rejected request, and render the returned result when it
|
||||
resolves. Never infer approval from elapsed time and never ask the user for an
|
||||
`nsec` as a fallback.
|
||||
|
||||
Archipelago recognizes a synchronous, user-triggered `getPublicKey()` as an
|
||||
account-selection action. An Archipelago-packaged app should still ask the host
|
||||
to show the identity chooser explicitly before login, especially when other
|
||||
asynchronous work happens between the click and the NIP-07 call. This prevents
|
||||
a returning user from being silently locked to the identity chosen on first use:
|
||||
|
||||
```js
|
||||
await window.archipelagoNostr?.selectIdentity?.()
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
```
|
||||
|
||||
`archipelagoNostr.selectIdentity()` is an optional host enhancement, not part of
|
||||
NIP-07. Apps must continue to work when it is absent (for example with a normal
|
||||
browser extension). Invoke it only from a deliberate login/account-switch
|
||||
action; routine signing calls should continue using the remembered identity.
|
||||
|
||||
If a first-launch choice should create the app account automatically, use the
|
||||
provider's sticky identity subscription and call the ordinary extension-login
|
||||
action from it:
|
||||
|
||||
```js
|
||||
const unsubscribe = window.archipelagoNostr?.onIdentitySelected?.(() => {
|
||||
if (!alreadyLoggedIn()) loginWithNip07()
|
||||
})
|
||||
```
|
||||
|
||||
The callback runs immediately when an identity was selected just before the
|
||||
React/Vue component mounted, closing the load-event race seen in browser tabs
|
||||
and Companion WebViews. Call `unsubscribe()` when the component unmounts. The
|
||||
selected public key remains available to the immediately following
|
||||
`getPublicKey()` call; do not add a timeout, reload, or second lookup between
|
||||
those operations. A plain `archipelago:identity` message remains available for
|
||||
backward compatibility, but it is not a reliable framework lifecycle API.
|
||||
|
||||
Submission testing for a Nostr-signed app must include:
|
||||
|
||||
1. `getPublicKey` allow, deny, and remembered consent;
|
||||
2. `signEvent` with a readable event-kind/content preview;
|
||||
3. the contained review → identity-ring loader → completion sequence;
|
||||
4. changing the selected identity and confirming remembered consent does not
|
||||
cross identity boundaries;
|
||||
5. HTTP and HTTPS dashboard frames, a `noopener` browser-tab launch, and the
|
||||
Android companion's standalone WebView;
|
||||
6. choosing an identity immediately when the first-launch picker appears, to
|
||||
prove the app's account store is ready before the result arrives; and
|
||||
7. Companion → **Open in browser** in a browser with no prior dashboard
|
||||
localStorage: complete the app gate, then prove the contained signer can
|
||||
choose an identity and sign without asking for a second node login; and
|
||||
8. after the first identity choice and after NIP-98 authentication, confirm the
|
||||
underlying app paints immediately—no black frame and no manual reload.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
Two different things enforce these, and it's worth knowing which is which:
|
||||
|
||||
@@ -173,6 +173,37 @@ override wins over the manifest in both directions and applies on the next
|
||||
request — your app cannot assume the gate is or isn't in front of it, so it
|
||||
must always enforce its own authorization for sensitive operations.
|
||||
|
||||
## Launch metadata
|
||||
|
||||
`metadata.launch` is consumed by catalog generation and the dashboard
|
||||
launcher. It is currently an extension rather than a Rust-validated field:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
requires_host_frame: false
|
||||
```
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `open_in_new_tab` | `false` | The app must be top-level because header repair cannot solve its OAuth/WebAuthn flow, JavaScript frame-busting, or strict cookies. Desktop opens a browser tab; Android uses its native in-app browser. |
|
||||
| `requires_host_frame` | `false` | Keep the app in the dashboard iframe even in the Android companion because it consumes an app-specific parent-frame integration. Standard Archipelago NIP-07 works in iframes, tabs, and the companion WebView without this flag; the platform renders consent inside the active app surface. |
|
||||
|
||||
Do not set both fields to `true`. The generated TypeScript launch tables are
|
||||
the runtime source used by the dashboard, so run
|
||||
`python3 scripts/generate-app-catalog.py` after changing either value. See
|
||||
[`app-developer-guide.md`](app-developer-guide.md#browser-iframe-and-companion-launch-modes)
|
||||
for the HTTP/HTTPS and test matrix.
|
||||
|
||||
Platform-owned same-origin mounts are not manifest features. If Archipelago
|
||||
adds one, its nginx location must send a fixed
|
||||
`X-Forwarded-Prefix: /app/<id>` header to the app gate whenever `proxy_pass`
|
||||
strips that prefix. The gate uses it for challenge form/assets and the
|
||||
post-login redirect; without it, a fresh external browser posts to the
|
||||
dashboard root and receives 405. Ordinary registry apps should declare a
|
||||
gated `interfaces.main` port instead of requesting such a mount.
|
||||
|
||||
## Volumes
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -52,13 +52,55 @@ built and verified to embed the alias fix. `cargo fmt` applied.
|
||||
| D1 shorty NPM crash-loop stopped cleanly (user-stopped marker; public hosts keep serving via host nginx mirror) | ✅ 12:52Z |
|
||||
| D2 shorty live nginx HSTS patch + reload | ✅ verified: :80 and :443 both answer `max-age=0` |
|
||||
| D3 Regenerate catalog (releases/app-catalog.json + store copies) | ✅ semantic diff = exactly the two NPM fixes |
|
||||
| D4 **User runs `scripts/sign-catalog.sh`** (signer built at /tmp/archy-sign-bin) | ⬜ waiting on mnemonic |
|
||||
| D5 Commit + push (origin + gitea-vps2 OTA mirror) | ✅ 6 commits pushed (signed catalog commits after D4) |
|
||||
| D6 Release v1.8.9-alpha: `scripts/create-release.sh 1.8.9-alpha` (mnemonic) → `scripts/publish-release-assets.sh 1.8.9-alpha gitea-vps2` | ⬜ waiting on mnemonic |
|
||||
| D7 OTA on shorty-s + framework-pt (Update button; framework-pt has no SSH from here) | ⬜ |
|
||||
| D8 shorty: clear the NPM user-stopped marker + Start (or it starts via the fixed catalog) | ⬜ |
|
||||
| D4 **User runs `scripts/sign-catalog.sh`** (signer built at /tmp/archy-sign-bin) | ✅ catalog signed + committed + pushed |
|
||||
| D5 Commit + push (origin + gitea-vps2 OTA mirror) | ✅ 9 commits pushed |
|
||||
| D6 Release v1.8.9-alpha: `scripts/create-release.sh 1.8.9-alpha` (mnemonic) → `scripts/publish-release-assets.sh 1.8.9-alpha gitea-vps2` | ✅ PUBLISHED (tag v1.8.9-alpha, releases/manifest.json live, backend+frontend assets verified by the script) |
|
||||
| D7 OTA on shorty-s + framework-pt (Update button; shorty is on 1.8.8-alpha, daily check — hit Update now) | ⬜ user action |
|
||||
| D8 shorty: clear the NPM user-stopped marker + Start (or it starts via the fixed catalog) | ✅ NPM LIVE-HEALED via the signed catalog: unit regenerated with both fixes, container up, admin UI HTTP 200 on :8081 (verified 15:42Z) |
|
||||
| D9 framework-pt: Start Mempool — its containers are confirmed stopped (port 4080 refuses; gate answers on 7778/8334/50002/18083 so those apps will embed over https immediately) | ⬜ |
|
||||
| D10 Post-deploy live checks: LND send+receive; mempool/IndeeHub/bitcoin-UI frames over https; NPM healthy + admin :8081; portainer token card on fresh DB; zero CORS errors | ⬜ |
|
||||
| D10 Post-deploy live checks: LND send+receive; mempool/IndeeHub/bitcoin-UI frames over https; NPM healthy + admin :8081 ✅; portainer token card on fresh DB; zero CORS errors | ⬜ after nodes update |
|
||||
|
||||
## E. Follow-ups discovered during the incident (ride the NEXT release, v1.8.10+)
|
||||
|
||||
- **LND channel-peer watchdog** (this release's headline platform fix): every
|
||||
2 minutes the daemon reconnects peers of open channels that LND has not
|
||||
re-established on its own (per-peer retry throttled to 10 minutes), using
|
||||
the peer's advertised addresses from the public graph. Kills the whole
|
||||
class this incident exposed — a channel unroutable ~17h after an LND update
|
||||
while both nodes looked healthy. Unit tests pin the selection logic over the
|
||||
live REST shapes.
|
||||
- **Funding-modal honesty fix** (1464b1b2): the
|
||||
Lightning "no channel" modal now states the node's real state — pending
|
||||
channel confirming / balance on the far side / payment couldn't route /
|
||||
genuinely no channels. Note the stale-direction defect it fixes: the
|
||||
payment-failure mapper never set the direction, so a SEND failure showed
|
||||
the RECEIVE-branch copy ("Receiving needs inbound liquidity…") — the exact
|
||||
modal users saw while their node had a healthy 583k-outbound channel.
|
||||
Both fixes have their v1.8.10 CHANGELOG + What's New entries staged so the
|
||||
next `create-release.sh 1.8.10-alpha` runs clean first time.
|
||||
- Nodes poll for OTA updates on `daily_check` — after publishing, tell the
|
||||
user to hit Update rather than wait for the next check.
|
||||
- `origin` remote had a stale pushurl with a dead token (pushes failed);
|
||||
fixed to the canonical repo URL, stale `~/.git-credentials` entry with an
|
||||
encoded port removed.
|
||||
|
||||
## F. Post-v1.8.9 verification on shorty-s (2026-09-01 evening)
|
||||
|
||||
- v1.8.9 applied; payment pipeline confirmed live: a 400,000 sat payment
|
||||
SUCCEEDED through the v2 router route; the 404s are gone.
|
||||
- App gate serves TLS on 4080/8334/18083/50002 (401 gate pages over https) —
|
||||
https app frames now answer. Mempool over https requires a hard refresh
|
||||
(PWA precaches the old bundle).
|
||||
- **"No route to the recipient" on sends is real**: the invoices being tested
|
||||
are from framework-pt, whose only channel (peer "Sandwich Farm",
|
||||
0224c955…) is flagged `disabled` on BOTH policy sides in the routing graph
|
||||
after today's node churn — the peer connection never re-established
|
||||
(LND's reconnect backoff can stretch to hours). A disabled edge is
|
||||
unroutable in both directions, so payments to/from framework-pt fail
|
||||
regardless of shorty's 583k outbound. Fix: `lncli connect` the peer, wait
|
||||
for the channel_update to re-enable the edge (~minutes), then re-test.
|
||||
- The 577k attempt earlier failed for a different, correct reason: it exceeded
|
||||
the channel's spendable balance (583,542 − 9,850 reserve ≈ 573k max).
|
||||
|
||||
framework-pt immediate workaround until its OTA lands: open the dashboard by
|
||||
IP (`http://192.168.x.x`) instead of `framework-pt.local`, and/or clear the
|
||||
|
||||
+230
-198
@@ -1,252 +1,284 @@
|
||||
# Nostr Git Source Hosting Plan
|
||||
|
||||
This plan describes how Archipelago can publish and accept contributions to its
|
||||
source code through `ngit`, NIP-34, and GRASP while keeping the developer
|
||||
experience inside Archipelago.
|
||||
**Reviewed:** 2026-09-11
|
||||
|
||||
## Goals
|
||||
**Status:** GitWorkshop integration is deployed and engineering-tested on the
|
||||
development node. The owner reported the corrected flows working and requested
|
||||
a production release on 2026-09-11. Canonical repository publication remains
|
||||
incomplete: the checkout has no Nostr repository configuration and `ngit` is
|
||||
not logged into the canonical maintainer identity. The signed production app
|
||||
catalog also does not yet carry GitWorkshop or its storefront promotion.
|
||||
|
||||
- Publish Archipelago source from a sanitized, fresh-history repository.
|
||||
- Make the in-app registry the primary onboarding path for contributors.
|
||||
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
|
||||
with a Nostr identity from their Archipelago node.
|
||||
- Follow the Bitcoin Core development model: broad public review and easy forks,
|
||||
with canonical merge authority held by a small maintainer set.
|
||||
- Give contributors full read, fork, and proposal rights, but no direct merge
|
||||
rights on the canonical repository.
|
||||
- Keep the official maintainer identity and merge authority separate from user
|
||||
node identities.
|
||||
The Android companion opens Source as a top-level page in its native in-app
|
||||
WebView. GitWorkshop's injected NIP-07 provider creates a small authenticated
|
||||
dashboard-origin signer broker within that page, so the app itself is never
|
||||
kept in a dashboard iframe. The App Store carries the upstream GitWorkshop
|
||||
icon, source-focused copy, and a dedicated contribution banner. Popular ordering and promotional
|
||||
placement are registry-owned `storefront` metadata rather than node-OS UI
|
||||
policy; these are also part of owner UAT.
|
||||
|
||||
## Current Building Blocks
|
||||
## Goal
|
||||
|
||||
Archipelago already has most of the primitives needed for this:
|
||||
Archipelago users can install a Source app from the app registry, obtain the
|
||||
Archipelago source, browse it, and contribute through the established Nostr Git
|
||||
ecosystem. Git remains the version-control engine, Nostr NIP-34 carries
|
||||
repository identity and collaboration events, and GRASP transports Git objects.
|
||||
|
||||
- App manifests and the app registry already install developer tooling as
|
||||
rootless Podman apps.
|
||||
- The `gitea` app provides a conventional fallback Git UI and package registry.
|
||||
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
|
||||
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
|
||||
- The backend exposes node and identity Nostr signing RPC methods.
|
||||
- FIPS gives nodes a stable mesh identity and private transport path, but repo
|
||||
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
|
||||
- DWN protocol registration exists and can be used later for local contribution
|
||||
metadata/cache, but should not be required for the first public workflow.
|
||||
The app must make public contribution easy without giving contributors direct
|
||||
merge or release authority. Canonical refs, merge status, release tags, and
|
||||
catalog signatures remain controlled by explicitly configured Archipelago
|
||||
maintainers.
|
||||
|
||||
## Protocol Basis
|
||||
## Product Decision
|
||||
|
||||
Use existing Nostr Git conventions rather than inventing an Archipelago-only
|
||||
protocol:
|
||||
Archipelago will package the upstream GitWorkshop web client instead of
|
||||
building another NIP-34 repository interface.
|
||||
|
||||
- NIP-34 repository announcement events identify repositories with kind `30617`.
|
||||
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
|
||||
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
|
||||
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
|
||||
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
|
||||
branches.
|
||||
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
|
||||
authority for repository identity, refs, PRs, issues, and maintainer state.
|
||||
GitWorkshop already provides repository discovery, a sparse Git explorer,
|
||||
issues, pull requests, and review workflows. Archipelago owns only the node
|
||||
integration around it:
|
||||
|
||||
- installable app metadata and a pinned upstream build;
|
||||
- a same-origin `/app/archipelago-source/` launch path that works through the
|
||||
dashboard address the user already opened, whether that is LAN, Tailscale,
|
||||
FIPS, DNS, IPv4, or IPv6;
|
||||
- authenticated routing through the existing app gate;
|
||||
- an injected, consent-gated NIP-07 provider so GitWorkshop can use a selected
|
||||
node identity without receiving its private key;
|
||||
- source provenance, security validation, upgrades, and rollback.
|
||||
|
||||
Archipelago will not duplicate GitWorkshop's repository browser, issue/PR,
|
||||
fork, diff, relay, or GRASP behavior in private `source.*` RPC methods.
|
||||
|
||||
Primary references:
|
||||
|
||||
- https://ngit.dev/how-it-works
|
||||
- https://github.com/DanConwayDev/gitworkshop
|
||||
- https://gitworkshop.dev/
|
||||
- https://nips.nostr.com/34
|
||||
- https://docs.rs/crate/ngit/latest/source/README.md
|
||||
- https://ngit.dev/grasp/
|
||||
|
||||
## Recommended Architecture
|
||||
## Trust And Permissions
|
||||
|
||||
### Apps
|
||||
- GitWorkshop runs as a static, read-only container behind the app gate.
|
||||
- The iframe may request NIP-07 operations through `postMessage`; only the
|
||||
exact launched frame and expected origin are accepted.
|
||||
- `getPublicKey`, event signing, encryption, and decryption require explicit
|
||||
dashboard consent. A remembered decision is scoped to node origin, app,
|
||||
selected identity, and method.
|
||||
- Contributor private keys never enter the GitWorkshop container.
|
||||
- Browser-origin signing calls from direct high-port app origins are rejected;
|
||||
they must pass through the dashboard consent bridge.
|
||||
- Maintainer and release keys must not be placed on ordinary user nodes.
|
||||
- Relay and GRASP data is untrusted. Canonical status is derived only from the
|
||||
signed repository announcement and configured maintainer identities.
|
||||
|
||||
Create two first-party apps:
|
||||
## Upstream Pin And Redistribution Decision
|
||||
|
||||
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
|
||||
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
|
||||
issues/PRs, opening branches, and submitting PR events.
|
||||
The development image currently pins GitWorkshop commit
|
||||
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da` (2026-07-28). The integration patch
|
||||
only adds base-path support and the Archipelago NIP-07 provider.
|
||||
|
||||
The `archipelago-source` app should depend on `ngit`. It can also recommend
|
||||
Gitea for users who want a conventional local web Git UI, but Gitea should not
|
||||
be the source of truth for public contribution permissions.
|
||||
The pinned revision and current upstream `main` have no license file, the npm
|
||||
package metadata declares no license, and GitHub reports no detected license.
|
||||
An earlier project-site description of “MIT” is not a license grant bundled
|
||||
with the code. On 2026-09-11 the Archipelago owner explicitly accepted the
|
||||
risk of publishing the patched build without an upstream license. That removes
|
||||
the project's internal release hold, but it does not turn the upstream code
|
||||
into open-source software or grant downstream rights. The absence is disclosed
|
||||
in `NOTICE` and the packaged `UPSTREAM.md`.
|
||||
|
||||
### Contributor Onboarding
|
||||
Preferred follow-up: ask upstream to add an SPDX-recognized license file
|
||||
(MIT if that remains their intent), then re-pin at or after that commit and add
|
||||
GitWorkshop plus its copyright/license notice to Archipelago's `NOTICE` and
|
||||
generated image inventory. A written grant that explicitly permits compiling,
|
||||
modifying, and redistributing this app is an alternative, but is harder for
|
||||
downstream users to audit. A public GitHub repository or permission to fork is
|
||||
not sufficient redistribution permission. Production dependency-audit findings
|
||||
must also be resolved or explicitly accepted before release.
|
||||
|
||||
When the user installs `archipelago-source` from the registry:
|
||||
The release-preparation audit on 2026-09-09 ran `npm audit --omit=dev` against
|
||||
the exact pinned commit and reported 4 high and 6 moderate advisories, with
|
||||
fixes available for every affected package. The same commit remains upstream
|
||||
`main`, so repinning alone does not resolve them. The final runtime image is
|
||||
static nginx rather than Node, which makes the Hono server findings unlikely to
|
||||
be runtime-reachable, but browser/runtime dependencies such as `fflate` and
|
||||
React Router still require an upstream dependency update or an explicit,
|
||||
written risk acceptance before registry publication.
|
||||
|
||||
1. Show a modal before first launch: "Contribute to Archipelago".
|
||||
2. Explain that the app will use their Archipelago Nostr identity to clone and
|
||||
sign contribution events.
|
||||
3. Display the maintainer repository announcement, clone URL, maintainer npub,
|
||||
and relay/GRASP endpoints.
|
||||
4. Ask for consent to:
|
||||
- fetch repository metadata from configured relays,
|
||||
- clone source through `nostr://`,
|
||||
- create local branches,
|
||||
- sign NIP-34 issue/PR/comment events,
|
||||
- push PR branches to approved GRASP servers.
|
||||
5. Store approval per app origin, identity id, repository id, and relay set.
|
||||
## Canonical Archipelago Repository
|
||||
|
||||
This should build on the existing NIP-07 app-launcher bridge, but use a more
|
||||
specific permission scope than the generic sign-event approval.
|
||||
The canonical announcement maintainer is
|
||||
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg`.
|
||||
The repository already contains a root MIT `LICENSE` and `CONTRIBUTING.md`;
|
||||
contributors agree to license their contributions under that MIT License.
|
||||
|
||||
### Identity And Permissions
|
||||
The user-facing Source app can ship for local evaluation before the canonical
|
||||
Archipelago Nostr repository exists, but it must not pretend a placeholder is
|
||||
canonical. Canonical launch requires:
|
||||
|
||||
Use four identity classes:
|
||||
1. A sanitized public `archy` source repository.
|
||||
2. An offline or tightly controlled maintainer identity.
|
||||
3. A signed NIP-34 kind `30617` repository announcement.
|
||||
4. At least one Archipelago-operated relay/GRASP endpoint and one independent
|
||||
compatible mirror.
|
||||
5. Tested `nostr://` clone, proposal, update, review, merge-status, server-loss,
|
||||
and recovery flows.
|
||||
6. A GitWorkshop link/configuration that opens the verified `archy` repository.
|
||||
|
||||
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
|
||||
signs the canonical kind `30617` repo announcement and status/merge events.
|
||||
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
|
||||
allowed to advance canonical refs and publish valid merged/applied status.
|
||||
- `archipelago-build`: release automation key for signed release artifacts and
|
||||
CI status events. It must not have merge authority.
|
||||
- `contributor`: user node or app-specific identity used for PRs, issues, and
|
||||
comments.
|
||||
The existing HTTP Git remote remains a fallback until those drills pass.
|
||||
|
||||
Contributor rights:
|
||||
## Delivery Milestones
|
||||
|
||||
- Clone the repository.
|
||||
- Open issues.
|
||||
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
|
||||
- Publish NIP-34 PR/update/comment events.
|
||||
- Rebase and update their own PR branch.
|
||||
- Run local validation and attach status evidence.
|
||||
### 1. Plan And Protocol Review — complete
|
||||
|
||||
Contributor restrictions:
|
||||
- Confirmed NIP-34/ngit/GRASP as the interoperability layer.
|
||||
- Defined contributor, maintainer, build, and release trust boundaries.
|
||||
- Confirmed that installation must ultimately come from the Archipelago app
|
||||
registry and include a path to the upstream/source code.
|
||||
|
||||
- Cannot update `refs/heads/main` or release branches in canonical state.
|
||||
- Cannot publish maintainer-valid merge/applied status.
|
||||
- Cannot alter the canonical repository announcement.
|
||||
- Cannot publish release catalog signatures.
|
||||
### 2. Runtime Feasibility — complete
|
||||
|
||||
Maintainer rights:
|
||||
- Validated pinned `ngit` and `git-remote-nostr` binaries on supported node
|
||||
architectures.
|
||||
- Exercised public `nostr://` discovery/clone behavior.
|
||||
- Established that app lifecycle dependencies do not share executables or
|
||||
filesystems, avoiding an invalid two-container CLI design.
|
||||
|
||||
- Publish/update the canonical repo announcement.
|
||||
- Publish canonical `refs/heads/main` state.
|
||||
- Mark PRs merged/closed/draft via NIP-34 status events.
|
||||
- Sign release tags and catalog updates.
|
||||
These CLI checks remain useful for canonical repository operations and release
|
||||
validation; they are not a reason to build a second browser client.
|
||||
|
||||
Fork rights:
|
||||
### 3. Node Integration Foundation — complete
|
||||
|
||||
- Any contributor can create their own NIP-34 kind `30617` repository
|
||||
announcement for a fork.
|
||||
- Fork announcements should use the NIP-34 `u` tag to point back to the
|
||||
canonical `archy` repository.
|
||||
- The source app should make forking a first-class path: "Fork on Nostr", clone
|
||||
the fork locally, push branches to the contributor's GRASP list, and open PRs
|
||||
back to canonical Archipelago when they want review.
|
||||
- Forks can have their own maintainer npubs, relays, policies, and release
|
||||
cadence, but the app should clearly label them as forks unless signed by the
|
||||
canonical maintainer set.
|
||||
- Added the installable app manifest, catalog metadata, icon, and port
|
||||
reservation.
|
||||
- Added identity selection and a generic consent-gated NIP-07 bridge.
|
||||
- Kept signing secrets out of the app container.
|
||||
|
||||
The GRASP server policy should enforce this by accepting pushes to maintainer
|
||||
refs only when backed by signed maintainer state, while allowing contributor PR
|
||||
refs from their own npubs.
|
||||
### 4. GitWorkshop Pivot — complete on the development node
|
||||
|
||||
## Repository Layout
|
||||
- Replace the prototype Source UI and all private `source.*` APIs with the
|
||||
pinned upstream GitWorkshop build.
|
||||
- Mount it below `/app/archipelago-source/` and proxy to the authenticated app
|
||||
gate, eliminating hard-coded address and high-port launch behavior.
|
||||
- Validate upstream base-path routing, static assets, browser refresh/deep
|
||||
links, NIP-07 requests, container hardening, and install/restart behavior.
|
||||
- Deploy the resulting daemon, dashboard, and app only on this development
|
||||
node, then hand it to the owner for UAT.
|
||||
|
||||
Canonical repo announcement:
|
||||
The pinned integration patch applies cleanly to a fresh upstream checkout. The
|
||||
upstream unit suite passes 152 tests, Archipelago's full frontend suite passes
|
||||
1,091 tests across 137 files, the production dashboard build and Android UAT
|
||||
lint/build pass, and the manifest passes all 16 validators. The read-only,
|
||||
capability-free container passes health, asset, manifest, and base-path checks.
|
||||
The live same-origin route reaches the authenticated app gate through the
|
||||
node's loopback, LAN, Tailscale, and FIPS addresses. A rollback snapshot is at
|
||||
`/var/backups/archipelago/pre-uat-fixes-20260908-1140` on the development node.
|
||||
|
||||
- repo id: `archy`
|
||||
- display name: `Archipelago`
|
||||
- clone URLs:
|
||||
- `nostr://<maintainer-npub>/<relay-hint>/archy`
|
||||
- `https://<grasp-host>/<maintainer-npub>/archy.git`
|
||||
- relays:
|
||||
- Archipelago-operated relay
|
||||
- at least two public Nostr relays that support the event load
|
||||
- GRASP servers:
|
||||
- Archipelago-operated GRASP instance
|
||||
- one public GRASP-compatible mirror
|
||||
### 5. Owner UAT — accepted, final release-candidate retest required
|
||||
|
||||
Keep the existing HTTP Git remote as a mirror during launch. The docs can
|
||||
present `nostr://` as the preferred contribution path once the workflow is
|
||||
proven.
|
||||
The owner exercised the corrected node deployment and requested release on
|
||||
2026-09-11. A short final retest remains required after the 0.5.32 release-channel
|
||||
Companion APK and canonical Archipelago repository deep-link replace their UAT
|
||||
counterparts; those two release inputs were not present in the accepted build.
|
||||
|
||||
## UI Requirements
|
||||
For companion testing, the node hosts a local-only Archipelago Companion
|
||||
`0.5.32-uat` at `/packages/archipelago-companion-0.5.32-uat.apk`. It uses the
|
||||
separate package ID `com.archipelago.app.uat`, installs beside the existing
|
||||
companion, and includes the native WebView launch plus Android's native node-CA
|
||||
installer. Its SHA-256 is
|
||||
`8924d7ba3a013e0db09a5f1e72c21de7886e5e21ed1b2e31d585495183191fe7`.
|
||||
The production companion download remains unchanged.
|
||||
|
||||
The source app should provide:
|
||||
Owner UAT should cover:
|
||||
|
||||
- A first-run contribution modal with a real Archipelago source graphic, not a
|
||||
generic text-only dialog.
|
||||
- Current clone status and local path.
|
||||
- Branch list, changed files, commit form, and push/open-PR flow.
|
||||
- PR inbox, issue list, maintainer status, and relay health.
|
||||
- Explicit identity indicator showing which npub will sign events.
|
||||
- A merge rights indicator that clearly says contributors can propose changes
|
||||
but cannot merge them.
|
||||
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
|
||||
then offers "Open PR to Archipelago" from any fork branch.
|
||||
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
|
||||
metadata or server-side account names.
|
||||
- Links to container docs, deployment docs, manifest spec, and open-source
|
||||
readiness tasks.
|
||||
1. Install/reinstall GitWorkshop from the local App Store and open it from the
|
||||
App Store, Apps screen, and Source banner. Confirm Discover shows Popular
|
||||
Apps first, the banner after two desktop rows, and the remaining catalog
|
||||
under All Apps; confirm the GitWorkshop mark is no longer the old icon.
|
||||
2. Confirm it opens as a top-level page in Companion's native in-app browser,
|
||||
not a dashboard iframe, and loads without a blank or "webpage unavailable"
|
||||
screen. Confirm Back and Close return through the Companion UI correctly.
|
||||
3. Select a node identity, exercise `getPublicKey` and signing prompts, verify
|
||||
the contained consent surface, short identity-circle loader, success/error,
|
||||
allow/deny/remember behavior, then change identity and confirm consent is
|
||||
requested again. Repeat this flow inside the Companion WebView.
|
||||
4. Edit a Nostr identity and confirm the identity-specific success screen shows
|
||||
the saved identity, relay coverage, event ID, copy action, and honest partial
|
||||
publish warning when a relay does not accept the update.
|
||||
5. Browse a known NIP-34 repository and exercise the contribution actions that
|
||||
GitWorkshop exposes without granting direct merge or release authority.
|
||||
6. From Companion settings, choose **Download this node's certificate** and
|
||||
confirm Android opens the system CA-install prompt for this node. Confirm
|
||||
the ordinary browser link still downloads the `.crt` file.
|
||||
7. Repeat launch through whichever of LAN, Tailscale, FIPS, DNS, IPv4, or IPv6
|
||||
is available; the app must follow the dashboard origin rather than a stored
|
||||
address. A raw numeric address works over HTTP; for HTTPS over Tailscale use
|
||||
the node's MagicDNS hostname because the certificate is issued to that name,
|
||||
not to the numeric Tailscale address.
|
||||
|
||||
## Backend Work
|
||||
UAT follow-up on 2026-09-08 found three integration defects: the mounted gate
|
||||
used root-relative form/assets and returned nginx 405 in a fresh mobile
|
||||
browser; silent signer requests flashed the full-screen broker frame in the
|
||||
Companion WebView; and IndeeHub reloaded while the signer's success surface was
|
||||
still closing, leaving Android WebView blank. The fixes are implemented with a
|
||||
validated forwarded mount, consent-driven broker visibility, and a coordinated
|
||||
post-auth reload plus native page-commit fallback. These items remain pending
|
||||
owner retest on the development node; their implementation is not UAT
|
||||
acceptance. The fixes were deployed locally on 2026-09-08. Live engineering
|
||||
checks confirm that mounted gate pages and assets retain the app prefix, gate
|
||||
POSTs return the application's 401 response instead of nginx 405 over HTTP and
|
||||
LAN HTTPS, both apps are healthy, and the served provider and UAT APK match
|
||||
their build hashes.
|
||||
|
||||
Add an RPC module for source contribution workflow:
|
||||
A further Companion retest showed a black surface immediately after the first
|
||||
identity selection even though authentication, reload, application data, and
|
||||
`/api/auth/me` all completed successfully. The common cause was the Android
|
||||
Chromium compositor retaining the hidden broker iframe's last full-screen black
|
||||
canvas. The broker route now has a genuinely transparent document, and hidden
|
||||
brokers stay loaded as a non-interactive 1px surface parked off-screen so the
|
||||
identity choice and immediately following sign request share one broker.
|
||||
Companion covers an expected authentication navigation with its branded loader
|
||||
until the app commits a new frame. This is deployed in `0.5.32-uat` and
|
||||
remains pending owner visual retest.
|
||||
|
||||
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
|
||||
maintainer npubs, and local clone state.
|
||||
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
|
||||
- `source.clone`: clones or updates the local source checkout.
|
||||
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
|
||||
- `source.commit`: creates a local commit from selected files.
|
||||
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
|
||||
remote.
|
||||
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
|
||||
- `source.update-pr`: updates the branch and publishes kind `1619`.
|
||||
- `source.issue`: publishes a kind `1621` event.
|
||||
### 6. Canonical Nostr Launch — pending
|
||||
|
||||
Backend must shell out through a narrow command wrapper, never arbitrary user
|
||||
commands. The wrapper should set an isolated working tree under
|
||||
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
|
||||
deny operations outside that path.
|
||||
- Publish and configure the signed `archy` kind `30617` announcement.
|
||||
- Bring up and test the chosen relays and GRASP servers.
|
||||
- Deep-link/configure GitWorkshop to the verified repository.
|
||||
- Run the real-node proposal and recovery drills listed above.
|
||||
|
||||
## Security Model
|
||||
### 7. Release — pending canonical publication and signing gates
|
||||
|
||||
- Never expose maintainer private keys to an Archipelago node.
|
||||
- Prefer app-specific contributor identities over the node's default identity.
|
||||
- Require per-action consent for first PR push, issue creation, and signing any
|
||||
event that tags the canonical repository.
|
||||
- Pin the canonical maintainer npub in the app manifest and backend config.
|
||||
- Keep the canonical merge-maintainer allow list signed by the
|
||||
`archipelago-maintainer` key; never infer merge rights from GRASP server
|
||||
accounts.
|
||||
- Verify the canonical kind `30617` event signature before displaying clone
|
||||
instructions.
|
||||
- Treat GRASP servers as untrusted storage; verify Git refs against signed
|
||||
Nostr state.
|
||||
- Do not use destructive git operations from the UI without an explicit modal.
|
||||
- Store local clones and generated patches outside app container writable roots
|
||||
unless the user exports them.
|
||||
Only after engineering tests, the final release-candidate retest, canonical
|
||||
launch tests, and dependency-risk disposition may the team:
|
||||
|
||||
## MVP
|
||||
- build and publish a production multi-architecture app image;
|
||||
- sign/update the production app-registry entry;
|
||||
- include the integration in an OTA or ISO;
|
||||
- add release notes and migration/rollback instructions.
|
||||
|
||||
1. Package `ngit` as a first-party app.
|
||||
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
|
||||
3. Publish sanitized fresh-history `archy` through `ngit init`.
|
||||
4. Add a simple `archipelago-source` app that clones source and links out to the
|
||||
preferred Nostr Git browser.
|
||||
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
|
||||
6. Allow issues and PR branch submission from contributor npubs.
|
||||
7. Add a one-click fork flow that publishes a contributor-owned fork
|
||||
announcement referencing canonical Archipelago.
|
||||
8. Keep maintainer merge/status publication manual.
|
||||
The production companion signing path also needs an explicit release decision.
|
||||
The current branch omits the shared debug keystore expected by
|
||||
`scripts/publish-companion-apk.sh` (an older repository revision contains it),
|
||||
while the local UAT key is intentionally unsuitable for public artifacts.
|
||||
Before publishing, verify upgrade compatibility against the already-distributed
|
||||
companion's signing certificate and stage only the intended production-signed
|
||||
APK.
|
||||
|
||||
## Later
|
||||
## Completed Next-OTA Follow-ups
|
||||
|
||||
- Native PR review UI with file diffs and inline comments.
|
||||
- CI status events signed by the build identity.
|
||||
- FIPS-first source sync between trusted Archipelago nodes.
|
||||
- Private prerelease repositories using NIP-42 allow lists and/or protected
|
||||
events if the ecosystem support is mature enough.
|
||||
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
|
||||
rotation events.
|
||||
- The container doctor detects a missing rootless Podman `pasta` listener and
|
||||
restarts only the affected container, including the intermittent Nginx Proxy
|
||||
Manager port 8081 case. TCP and UDP bindings are checked independently.
|
||||
- The node-certificate UI contains the approved macOS, iOS/iPadOS, Windows,
|
||||
Android, Linux, browser restart, DNS, and symptom/cause guidance, while the
|
||||
Companion hands the downloaded node CA to Android's system installer.
|
||||
|
||||
## Open Questions
|
||||
## Open Decisions Before Canonical Launch
|
||||
|
||||
- Which maintainer npub should become canonical for `archy`?
|
||||
- Should contributor identities be node-default or app-specific by default?
|
||||
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
|
||||
NIP-34/GRASP-compatible relay?
|
||||
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
|
||||
browser links for review while keeping signing/submission native?
|
||||
- What exact license and contribution certificate should contributors accept
|
||||
before submitting PR events?
|
||||
- Which Archipelago-operated and independent relay/GRASP endpoints are used?
|
||||
- Does the owner accept the recorded 4 high and 6 moderate GitWorkshop npm
|
||||
advisories for this release, or must the integration patch update them first?
|
||||
- Will upstream add an explicit GitWorkshop license as a post-release
|
||||
clarification for downstream users?
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# OpenWrt Gateway Setup
|
||||
|
||||
How to connect an OpenWrt router to an Archipelago node and, optionally, turn
|
||||
it into a pay-as-you-go WiFi gateway with **TollGate**. Written for a node
|
||||
operator following the UI; a developer-facing RPC/architecture reference is
|
||||
at the bottom.
|
||||
|
||||
This feature manages a **separate physical (or virtual) router** running
|
||||
OpenWrt over SSH/UCI — it is not a containerized app. Archipelago itself does
|
||||
not flash or install OpenWrt; you bring a router that already runs it.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Status dashboard**: hostname, uptime, firmware release, WiFi interfaces,
|
||||
WAN state — polled live from the router.
|
||||
- **WAN/WISP wizard**: point the router's radio at an upstream WiFi network
|
||||
(turns it into a wireless bridge/repeater) with DHCP + NAT configured for
|
||||
you.
|
||||
- **TollGate provisioning** (optional): installs the
|
||||
[TollGate](https://tollgate.me) captive-portal package
|
||||
(`tollgate-module-basic-go`) and stands up an `archipelago` SSID that
|
||||
sells timed internet access for sats, settled against this node's local
|
||||
Cashu mint.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **A router already flashed with OpenWrt.** Check the
|
||||
[OpenWrt Table of Hardware](https://openwrt.org/toh/start) for your model
|
||||
and follow OpenWrt's own install/flashing instructions — that part is
|
||||
outside Archipelago's scope. See below for a worked example (GL.iNet
|
||||
AX3000).
|
||||
2. **SSH reachable.** Fresh OpenWrt images enable `dropbear` (SSH) on LAN by
|
||||
default, listening as `root` with no password (or the password you set
|
||||
during OpenWrt's first-boot wizard at `192.168.1.1`). Archipelago
|
||||
connects with `ssh2` over a password (key-based auth is supported at the
|
||||
library level but the UI only offers password so far).
|
||||
3. **Same LAN as the Archipelago node**, at least for setup — plug the
|
||||
router's LAN port into the same switch/network segment the node is on.
|
||||
4. **For TollGate**: a running Cashu mint app (`nutshell`/`cashu-mint`) on
|
||||
this node — provisioning defaults `mint_url` to
|
||||
`http://<node-ip>:3338` and TollGate customers must be able to reach that
|
||||
URL from outside the node's loopback.
|
||||
|
||||
## Worked example: flashing a GL.iNet AX3000 to stock OpenWrt
|
||||
|
||||
GL.iNet's "AX3000" travel router is the **Beryl AX (GL-MT3000)** —
|
||||
MediaTek MT7981B (Cortex-A53), OpenWrt target `mediatek/filogic`. It ships
|
||||
running a GL.iNet fork of OpenWrt with its own web UI and LuCI already
|
||||
enabled, but the steps below replace that with stock/vanilla OpenWrt so it
|
||||
matches the prebuilt TollGate `.ipk` architectures exactly
|
||||
(`aarch64_cortex-a53`).
|
||||
|
||||
1. **Download the sysupgrade image** for the current stable release from
|
||||
`https://downloads.openwrt.org/releases/<version>/targets/mediatek/filogic/`
|
||||
— the file you want is
|
||||
`openwrt-<version>-mediatek-filogic-glinet_gl-mt3000-squashfs-sysupgrade.bin`.
|
||||
2. **Verify the checksum** against the `sha256sums` file in that same
|
||||
directory before flashing anything.
|
||||
3. **Flash from the GL.iNet UI**: on the router's default address
|
||||
(`192.168.8.1`), go to **More Settings → Upgrade → Local Upgrade**, or
|
||||
open **Advanced → LuCI** and use **System → Backup / Flash Firmware →
|
||||
Flash new firmware image**.
|
||||
4. Upload the `.bin` file. **Uncheck "Keep Settings"** — going from the
|
||||
GL.iNet fork to stock OpenWrt needs a clean reset, not a config carry-over.
|
||||
5. Confirm and wait ~3–5 minutes without power-cycling the router.
|
||||
6. **After it reboots** you're on stock OpenWrt: LAN at `192.168.1.1`, DHCP
|
||||
on, SSH (dropbear) open as `root` with **no password set yet** — set one
|
||||
via LuCI at `192.168.1.1` or `passwd` over SSH before doing anything else.
|
||||
From here, continue with the Prerequisites/Step 2 flow above to connect
|
||||
it to the Archipelago node.
|
||||
|
||||
> The Archipelago UI's Connect form (Step 2) authenticates *with* a
|
||||
> password — it has no flow for setting the initial one on a fresh,
|
||||
> passwordless router. You have to set it out-of-band first. If you're
|
||||
> working from the node's own local kiosk display rather than a normal
|
||||
> desktop browser, there's no visible tab bar/address bar to open a new
|
||||
> tab from — press **Ctrl+T** to open one anyway, navigate to
|
||||
> `192.168.1.1`, and use LuCI's first-boot prompt to set the root
|
||||
> password. Then switch back to the Archipelago tab and Connect with it.
|
||||
|
||||
**If the flash fails / the router doesn't come back**: filogic devices
|
||||
don't use a reset-button recovery. Instead, connect to the router's LAN
|
||||
port and, during boot, press a key within the first ~2 seconds to enter
|
||||
U-Boot; per the OpenWrt wiki, typing `gl` then `httpd` at the U-Boot prompt
|
||||
brings up a recovery web UI at `192.168.1.2` that accepts a firmware image.
|
||||
|
||||
## Step 1: Open the OpenWrt Gateway panel
|
||||
|
||||
1. In the Archipelago UI, go to **Server**.
|
||||
2. Under the network status list, click **OpenWrt Gateway**
|
||||
(`/dashboard/server/openwrt`).
|
||||
|
||||
If no router has been connected before, you'll land on the connect form.
|
||||
|
||||
## Step 2: Connect the router
|
||||
|
||||
You have two options:
|
||||
|
||||
- **Detect**: click **Detect** — this reads the node's own active wired
|
||||
Ethernet interface, derives its subnet, and probes every host on it for
|
||||
`TCP/22` + a valid `/etc/openwrt_release`. If it finds exactly one router
|
||||
it fills in the host automatically; if it finds several you pick from the
|
||||
list. A `/24` scan can take up to ~2 minutes (255 sequential probes at
|
||||
500 ms each on hosts that don't respond).
|
||||
- **Manual**: type the router's LAN IP (commonly `192.168.1.1` on a router
|
||||
freshly bridged in, or whatever address it has on your network) plus the
|
||||
SSH username (default `root`) and password.
|
||||
|
||||
Click **Connect**. On success the panel switches to the status dashboard and
|
||||
the connection (host + credentials) is persisted server-side — you won't
|
||||
need to re-enter them on future visits or from other views (e.g. the Home
|
||||
dashboard's network tile also polls this without prompting again).
|
||||
|
||||
> Credentials are stored in `router_config.json` under the node's data
|
||||
> directory alongside other node config. There's no separate secrets
|
||||
> vault entry for this yet — treat the router's SSH password like any other
|
||||
> node-local config.
|
||||
|
||||
## Step 3: (Optional) Configure WAN/WISP
|
||||
|
||||
Use this to make the OpenWrt router pull its internet connection from an
|
||||
upstream WiFi network instead of a wired uplink — useful for a
|
||||
battery/off-grid TollGate node or extending coverage from an existing
|
||||
network.
|
||||
|
||||
1. From the status dashboard, start the **WAN setup** wizard.
|
||||
2. **Scan** — the router's radio scans for visible networks (a few seconds
|
||||
of SSH round-trips).
|
||||
3. **Select network** — pick the upstream SSID from the list.
|
||||
4. **Password** — enter the upstream network's WiFi password (encryption
|
||||
defaults to `psk2`; leave blank only for open networks).
|
||||
5. **DHCP / NAT** — review the LAN DHCP pool (default `.100`–`.249`) and
|
||||
whether to enable NAT/masquerade on the WAN zone (leave this on unless
|
||||
you have a specific reason not to).
|
||||
6. **Connect** — this writes a `wwan` STA `wifi-iface` + `network` interface
|
||||
over UCI, enables the radio if it was disabled (OpenWrt ships with
|
||||
`radio0.disabled=1` on a fresh flash), and adds `wwan` to the WAN
|
||||
firewall zone.
|
||||
|
||||
The dashboard's WAN panel shows the resulting association state, assigned
|
||||
IP, and whether the router currently has internet reachability.
|
||||
|
||||
## Step 4: (Optional) Install TollGate
|
||||
|
||||
Once connected (and with a local Cashu mint app running), the dashboard
|
||||
shows a **TollGate: not installed** panel with a single **Install TollGate**
|
||||
button — there's no config form at this stage, it installs with defaults.
|
||||
The panel itself warns: *"Router needs internet access to install TollGate
|
||||
— configure WAN above first"* (Step 3), since the router has to reach the
|
||||
internet to download the package.
|
||||
|
||||
1. Click **Install TollGate**. The button relabels to *"Installing… this
|
||||
may take a few minutes"* while it works.
|
||||
2. Under the hood this installs `tollgate-module-basic-go` on the router
|
||||
(via `opkg` on OpenWrt ≤24.x, or a manual `.ipk` extract on 25.x images
|
||||
where `opkg` isn't available), writes `/etc/tollgate/config.json`, and
|
||||
creates the `archipelago` SSID — all with default pricing (10 sats per
|
||||
1-minute step, minimum 1 step, `mint_url` auto-filled to
|
||||
`http://<node-ip>:3338`, enabled).
|
||||
3. On success you'll see *"TollGate provisioned successfully"* and the
|
||||
panel switches to the installed view (Enabled/Disabled badge, current
|
||||
price/step/mint).
|
||||
|
||||
### Configuring price, step size, or mint (after install)
|
||||
|
||||
The installed-state panel has an **Edit** button — this is the only place
|
||||
you set price/step/mint, and it only appears once TollGate is already
|
||||
installed:
|
||||
|
||||
1. Click **Edit**.
|
||||
2. Set **Price** (sats), **Step size** (minutes — billed as `step_size_ms`
|
||||
under the hood), **Minimum steps** a customer must buy at once, **Mint
|
||||
URL** (leave as the auto-filled node URL unless pointing at an external
|
||||
mint), and the **Enable TollGate** toggle.
|
||||
3. Click **Save**. Changes are pushed to `/etc/tollgate/config.json` and the
|
||||
daemon is restarted to pick them up — it does not hot-reload.
|
||||
|
||||
Anyone who joins the `archipelago` SSID sees TollGate's captive portal and
|
||||
pays sats (via the configured Cashu mint) for timed access.
|
||||
|
||||
## Verifying a successful install
|
||||
|
||||
A clean install (flash → Connect → WAN/WISP → Install TollGate, all through
|
||||
the UI as above) ends in this state — worth checking if you want to confirm
|
||||
everything actually landed correctly rather than trusting the UI's success
|
||||
toast alone:
|
||||
|
||||
- `tollgate-wrt` is running (`/etc/init.d/tollgate-wrt status` → `running`).
|
||||
- nodogsplash's **rendered** config — not just the UCI source — has
|
||||
`GatewayInterface br-tollgate`. Check the actual file the daemon was
|
||||
started with (typically `/tmp/etc/nodogsplash_main.conf`), since that's
|
||||
what's actually enforced, not `uci show nodogsplash`. This matters because
|
||||
provisioning must stop nodogsplash and reconfigure it to gate the
|
||||
`br-tollgate` bridge *before* starting it — installing the package by hand
|
||||
(bypassing the UI/RPC flow) leaves nodogsplash on its default
|
||||
`br-lan`-gating behavior instead, which locks out the router's own
|
||||
admin/SSH access. If you ever see a router become unreachable right after
|
||||
a TollGate install, this is the first thing to check.
|
||||
- The router's own LAN (the interface you manage it over — SSH, ping) is
|
||||
still reachable and untouched by the portal.
|
||||
- TollGate's own log (`logread | grep tollgate-wrt`) shows successful mint
|
||||
probes for each configured mint.
|
||||
|
||||
A `dev build detected (branch=unknown), injecting test mint:
|
||||
https://nofee.testnut.cashu.space` line in that log means the installed
|
||||
build considers itself a dev build and silently adds a test mint alongside
|
||||
your configured one(s) — check the Edit panel's Mint URL afterward if you
|
||||
don't want that test mint accepted.
|
||||
|
||||
### A note on network topology during setup
|
||||
|
||||
If the Archipelago node reaches the router over the same wired interface the
|
||||
router uses as its LAN, expect the router to become the node's default
|
||||
route on that interface once it has its own working WAN/WISP uplink — this
|
||||
is normal and, once WAN is actually configured with internet access, works
|
||||
fine end-to-end (the node's traffic routes out through the router's
|
||||
uplink). It's only a problem *before* WAN is configured: a freshly flashed
|
||||
or freshly factory-reset router has no upstream internet yet, so if it wins
|
||||
the node's default-route race (lowest metric on its own interface) while
|
||||
still offline, it creates a dead-end route and the node loses its own
|
||||
connectivity (including anything tunneled, e.g. a VPN/mesh network the node
|
||||
relies on) until that route is removed or the router gets its uplink
|
||||
working. If you hit this, either wait until WAN/WISP is actually up before
|
||||
letting the router's interface win the route race, or temporarily lower the
|
||||
priority of that route until it is.
|
||||
|
||||
## Reconfiguring or moving to a different router
|
||||
|
||||
Use **Disconnect** on the status dashboard to return to the connect form —
|
||||
this only clears the panel's client-side state, it doesn't delete the
|
||||
persisted `router_config.json`, so reconnecting to the same router needs no
|
||||
re-entry. To point at a *different* router, disconnect and connect with a
|
||||
new host/credentials; the newly connected router becomes the persisted one.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No router configured"**: nothing has been connected yet, or the saved
|
||||
config didn't include a host — go through Step 2 again.
|
||||
- **Connect hangs or times out**: the router isn't reachable on `TCP/22`
|
||||
from the node's network, or SSH auth failed. Confirm you can `ssh
|
||||
root@<router-ip>` manually from the node (or a machine on the same LAN)
|
||||
with the same credentials.
|
||||
- **Router "moved networks" / stale saved host**: SSH/status calls are
|
||||
bounded (5s TCP connect, 30s read/write) precisely so an unreachable
|
||||
saved router can't stall other RPCs — but the dashboard will show a
|
||||
connection error until you reconnect with the router's current address.
|
||||
- **TollGate provision fails with "No pre-built TollGate package for
|
||||
architecture..."**: your router's SoC isn't one of the prebuilt
|
||||
`.ipk` targets (`mips_24kc`, `mipsel_24kc`, `aarch64_cortex-a53`,
|
||||
`aarch64_cortex-a72`, `arm_cortex-a7`). You'll need a custom opkg feed or
|
||||
to build `tollgate-module-basic-go` from source for your architecture.
|
||||
- **TollGate download looks like it succeeded but provisioning still
|
||||
fails**: the node sanity-checks the downloaded `.ipk` is at least 50 KB —
|
||||
a smaller file usually means `wget` captured an HTML error page instead
|
||||
(no internet access from the router, or a bad release URL).
|
||||
- **Install fails right after a reboot or a fresh WAN setup** with `apk
|
||||
update failed ... router may have no internet access` even though WAN
|
||||
looks configured: this is usually just timing, not a real problem — the
|
||||
router's WiFi-uplink association (`wwan`/`hakodosh`-style STA interface)
|
||||
can take a few seconds longer to reconnect than the dashboard takes to
|
||||
let you click Install. Wait ~10–15 seconds after WAN shows `sta_state:
|
||||
up` and retry; it should succeed on the next attempt.
|
||||
- **Install fails with `opkg not found at /usr/bin/opkg` (or similar) even
|
||||
though the router clearly has `opkg`/`apk` installed**: fixed as of
|
||||
2026-09-05 — the backend used to hardcode `/usr/bin/opkg`/`/usr/bin/apk`,
|
||||
which some official OpenWrt builds don't symlink into `/bin`. If you're
|
||||
running an Archipelago build from before that fix, update first.
|
||||
|
||||
---
|
||||
|
||||
## Developer reference
|
||||
|
||||
Backend crate: `core/openwrt` (`archipelago-openwrt`) — SSH/UCI plumbing,
|
||||
WAN/WISP config, WiFi scanning, and TollGate install/config. See
|
||||
[`architecture.md`](architecture.md) for where it sits in the workspace.
|
||||
|
||||
RPC methods (`core/archipelago/src/api/rpc/openwrt.rs`, dispatched in
|
||||
`core/archipelago/src/api/rpc/dispatcher.rs`):
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `openwrt.scan` | Probe a subnet for OpenWrt routers (`subnet`, `prefix`, `ssh_user`, `ssh_password`) |
|
||||
| `openwrt.get-status` | Full status: release, WiFi interfaces, WAN, TollGate state. No params → uses saved `router_config.json`; params with `host` also persist the connection |
|
||||
| `openwrt.configure-wan` | Write WISP/WAN config (`ssid`, `password`, `encryption`, `dhcp_start`, `dhcp_limit`, `masq`) |
|
||||
| `openwrt.scan-wifi` | Radio scan for visible upstream networks |
|
||||
| `openwrt.provision-tollgate` | Install/reconfigure TollGate (`price_sats`, `step_size_ms`, `min_steps`, `mint_url`, `enabled`) |
|
||||
|
||||
Note: these are distinct from the unrelated `router.*` methods
|
||||
(`router.discover`, `router.configure`, `router.list-forwards`, ...), which
|
||||
handle UPnP/NAT-PMP port forwarding on the node's own upstream home router —
|
||||
not the OpenWrt gateway feature described here.
|
||||
|
||||
Frontend: `neode-ui/src/views/server/OpenWrtGateway.vue`, routed at
|
||||
`server/openwrt` (`neode-ui/src/router/index.ts`), linked from
|
||||
`neode-ui/src/views/Server.vue`.
|
||||
|
||||
Persisted connection state: `router_config.json` in the node's data
|
||||
directory (`core/archipelago/src/network/router.rs`:
|
||||
`load_router_config`/`save_router_config`).
|
||||
@@ -262,7 +262,12 @@ ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
|
||||
# were added to the Dockerfile below — the cache condition never looked at
|
||||
# the recipe. Hash the rootfs-defining region of this script; any edit to it
|
||||
# forces a rebuild. `--rebuild` still forces one unconditionally.
|
||||
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
|
||||
RECIPE_HASH=$(
|
||||
{
|
||||
sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0"
|
||||
cat "$SCRIPT_DIR/../configs/install-ngit.sh"
|
||||
} | sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
|
||||
echo " Using Docker to create Debian root filesystem..."
|
||||
@@ -451,6 +456,13 @@ COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
|
||||
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
|
||||
|
||||
# Install the pinned Nostr Git runtime. The installer verifies the release
|
||||
# archive before copying ngit and git-remote-nostr into /usr/bin.
|
||||
COPY install-ngit.sh /tmp/install-ngit.sh
|
||||
RUN chmod 0755 /tmp/install-ngit.sh && \
|
||||
/tmp/install-ngit.sh && \
|
||||
rm /tmp/install-ngit.sh
|
||||
|
||||
# Configure locale
|
||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
||||
|
||||
@@ -796,6 +808,11 @@ NGINXCONF
|
||||
echo " Using nostr-relay-config.toml from configs/"
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/../configs/install-ngit.sh" ]; then
|
||||
cp "$SCRIPT_DIR/../configs/install-ngit.sh" "$WORK_DIR/install-ngit.sh"
|
||||
echo " Using pinned ngit installer from configs/"
|
||||
fi
|
||||
|
||||
# Copy WireGuard helper script (privileged peer management)
|
||||
if [ -f "$SCRIPT_DIR/../../scripts/archipelago-wg" ]; then
|
||||
cp "$SCRIPT_DIR/../../scripts/archipelago-wg" "$WORK_DIR/archipelago-wg"
|
||||
@@ -1584,7 +1601,7 @@ IMAGES_CAPTURED_FROM_SERVER=0
|
||||
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||
echo " Capturing container images from live server ($DEV_SERVER)..."
|
||||
# Patterns match against `podman images` repository names (not container names)
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager adguard"
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager"
|
||||
REMOTE_TMP="/tmp/archipelago-image-capture-$$"
|
||||
SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true
|
||||
for p in $SAVED_LIST; do
|
||||
@@ -1631,7 +1648,6 @@ ${PHOTOPRISM_IMAGE} photoprism.tar
|
||||
${NEXTCLOUD_IMAGE} nextcloud.tar
|
||||
${NPM_IMAGE} nginx-proxy-manager.tar
|
||||
${ONLYOFFICE_IMAGE} onlyoffice.tar
|
||||
${ADGUARDHOME_IMAGE} adguardhome.tar
|
||||
"
|
||||
|
||||
# Pull and save each image (force target arch) only if not already present
|
||||
|
||||
@@ -6,7 +6,7 @@ After=archipelago.service
|
||||
Type=oneshot
|
||||
# Runs as root: needs to kill orphaned conmon processes, fix permissions
|
||||
User=root
|
||||
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
|
||||
ExecStart=/opt/archipelago/scripts/container-doctor.sh --local
|
||||
TimeoutStartSec=300
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# Install the exact ngit runtime validated for Archipelago source hosting.
|
||||
#
|
||||
# The GitHub release archive contains both `ngit` and `git-remote-nostr`.
|
||||
# Keep version, filenames, and SHA-256 values together so image builds and
|
||||
# OTA updates cannot silently resolve a newer upstream release.
|
||||
|
||||
set -eu
|
||||
|
||||
NGIT_VERSION="2.6.3"
|
||||
NGIT_RELEASE_BASE="https://github.com/DanConwayDev/ngit-cli/releases/download/v${NGIT_VERSION}"
|
||||
X86_64_ASSET="ngit-v${NGIT_VERSION}-x86_64-unknown-linux-gnu.2.17.tar.gz"
|
||||
X86_64_SHA256="81dd9b6a11a4a0feb946e56f55d557dc24075f1dcdda00ac35f9fd01920b9779"
|
||||
AARCH64_ASSET="ngit-v${NGIT_VERSION}-aarch64-unknown-linux-gnu.2.17.tar.gz"
|
||||
AARCH64_SHA256="e9d9437b7574e729b5a5d5cd800ebd668b73e6eb5c5859d52f83114c2f4b08b8"
|
||||
|
||||
install_root="${ARCHIPELAGO_NGIT_INSTALL_ROOT:-}"
|
||||
case "$install_root" in
|
||||
""|/*) ;;
|
||||
*)
|
||||
echo "ARCHIPELAGO_NGIT_INSTALL_ROOT must be empty or absolute" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
install_dir="${install_root}/usr/bin"
|
||||
ngit_bin="${install_dir}/ngit"
|
||||
helper_bin="${install_dir}/git-remote-nostr"
|
||||
|
||||
if [ -x "$ngit_bin" ] && [ -x "$helper_bin" ] && \
|
||||
[ "$($ngit_bin --version 2>/dev/null || true)" = "ngit ${NGIT_VERSION}" ] && \
|
||||
[ "$($helper_bin --version 2>/dev/null || true)" = "v${NGIT_VERSION}" ]; then
|
||||
echo "ngit ${NGIT_VERSION} already installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
machine="${ARCHIPELAGO_NGIT_ARCH:-$(uname -m)}"
|
||||
case "$machine" in
|
||||
x86_64|amd64)
|
||||
asset="$X86_64_ASSET"
|
||||
expected_sha256="$X86_64_SHA256"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
asset="$AARCH64_ASSET"
|
||||
expected_sha256="$AARCH64_SHA256"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported ngit architecture: $machine" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
download_dir=$(mktemp -d -t archipelago-ngit.XXXXXX)
|
||||
cleanup() {
|
||||
rm -rf -- "$download_dir"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
archive="$download_dir/$asset"
|
||||
curl --fail --silent --show-error --location \
|
||||
--proto '=https' --tlsv1.2 \
|
||||
--retry 3 --connect-timeout 20 \
|
||||
--output "$archive" "$NGIT_RELEASE_BASE/$asset"
|
||||
|
||||
actual_sha256=$(sha256sum "$archive" | awk '{print $1}')
|
||||
if [ "$actual_sha256" != "$expected_sha256" ]; then
|
||||
echo "ngit archive checksum mismatch for $asset" >&2
|
||||
echo "expected: $expected_sha256" >&2
|
||||
echo "actual: $actual_sha256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract only the two expected top-level files. Unexpected archive content is
|
||||
# never copied into the host filesystem.
|
||||
tar -xzf "$archive" -C "$download_dir" ngit git-remote-nostr
|
||||
mkdir -p "$install_dir"
|
||||
install -m 0755 "$download_dir/ngit" "$ngit_bin"
|
||||
install -m 0755 "$download_dir/git-remote-nostr" "$helper_bin"
|
||||
|
||||
[ "$($ngit_bin --version)" = "ngit ${NGIT_VERSION}" ]
|
||||
[ "$($helper_bin --version)" = "v${NGIT_VERSION}" ]
|
||||
echo "installed ngit ${NGIT_VERSION} for $machine"
|
||||
@@ -52,6 +52,17 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
|
||||
# companion WebView. This document alone may be framed by another port on
|
||||
# the same node; signing RPCs still require an authenticated node session.
|
||||
location = /nostr-signer {
|
||||
try_files /index.html =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
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;
|
||||
}
|
||||
|
||||
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
|
||||
#
|
||||
# /aiui/-scoped CSP (AIUI-04, D-19 unaffected — this is a build-time/
|
||||
@@ -691,13 +702,32 @@ server {
|
||||
sub_filter "src='/" "src='/app/botfights/";
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script><script>window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="arcade-input"&&d.key){var t=d.action==="up"?"keyup":"keydown";document.dispatchEvent(new KeyboardEvent(t,{key:d.key,bubbles:true}))}})</script></head>';
|
||||
}
|
||||
# GitWorkshop follows the dashboard origin so every supported ingress
|
||||
# works without separately publishing an app port. The app gate on
|
||||
# 127.0.0.2 preserves session authentication before forwarding to the
|
||||
# loopback-only container.
|
||||
location /app/archipelago-source/ {
|
||||
proxy_pass http://127.0.0.2:8337/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location /app/gitea/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1G;
|
||||
client_max_body_size 10G;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
# Override parent add_header to allow iframe embedding
|
||||
@@ -1037,6 +1067,16 @@ server {
|
||||
return 504 '{"error":{"code":"BACKEND_TIMEOUT","message":"Service did not respond in time"}}';
|
||||
}
|
||||
|
||||
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
|
||||
# companion WebView. Keep this aligned with the HTTP server block.
|
||||
location = /nostr-signer {
|
||||
try_files /index.html =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
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;
|
||||
}
|
||||
|
||||
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
|
||||
#
|
||||
# /aiui/-scoped CSP — see the HTTP server block above for the full
|
||||
@@ -1479,4 +1519,3 @@ server {
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,27 @@ location /app/uptime-kuma/ {
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
# GitWorkshop follows the dashboard origin; the app gate keeps the route
|
||||
# session-authenticated before it reaches the loopback-only container.
|
||||
location /app/archipelago-source/ {
|
||||
proxy_pass http://127.0.0.2:8337/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location /app/gitea/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
client_max_body_size 10G;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const providerSource = readFileSync(
|
||||
resolve(process.cwd(), 'public/nostr-provider.js'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('mobile Chromium returns to the live app after the signer is hidden', async ({ context, page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
|
||||
await context.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname === '/nostr-provider.js') {
|
||||
await route.fulfill({ contentType: 'application/javascript', body: providerSource })
|
||||
return
|
||||
}
|
||||
if (url.port === '' && url.pathname === '/nostr-signer') {
|
||||
await route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<!doctype html><html><body style="margin:0;background:#111827">
|
||||
<script>
|
||||
addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'archipelago:signer-init') {
|
||||
parent.postMessage({ type: 'archipelago:signer-show' }, event.origin)
|
||||
setTimeout(() => {
|
||||
parent.postMessage({
|
||||
type: 'archipelago:signer-identity',
|
||||
identity: { nostr_pubkey: 'browser-handoff-key' },
|
||||
}, event.origin)
|
||||
parent.postMessage({ type: 'archipelago:signer-hide' }, event.origin)
|
||||
}, 50)
|
||||
}
|
||||
})
|
||||
parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
|
||||
</script>
|
||||
</body></html>`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.port === '7778') {
|
||||
await route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<!doctype html><html><head><title>IndeedHub</title>
|
||||
<script src="/nostr-provider.js" data-no-nip98></script>
|
||||
<style>html,body{margin:0;background:#166534}#app{height:100vh;color:white;display:grid;place-items:center}</style>
|
||||
</head><body><main id="app">app-ready</main><script>
|
||||
addEventListener('load', () => {
|
||||
archipelagoNostr.onIdentitySelected((identity) => {
|
||||
document.querySelector('#app').textContent = 'signed-in:' + identity.nostr_pubkey
|
||||
})
|
||||
})
|
||||
</script></body></html>`,
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.abort()
|
||||
})
|
||||
|
||||
await page.goto('http://app.test:7778/')
|
||||
await expect(page.locator('#app')).toHaveText('signed-in:browser-handoff-key')
|
||||
|
||||
const broker = page.locator('#archipelago-nostr-signer')
|
||||
await expect(broker).toHaveCount(1)
|
||||
await expect(broker).toHaveCSS('width', '1px')
|
||||
await expect(broker).toHaveCSS('height', '1px')
|
||||
await expect(broker).toHaveCSS('opacity', '0')
|
||||
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(22, 101, 52)')
|
||||
expect(await page.evaluate(() => document.elementFromPoint(195, 422)?.id)).toBe('app')
|
||||
})
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.9-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.9-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.9-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -13,6 +13,9 @@ export default defineConfig({
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'off',
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: process.env.ARCHY_CHROMIUM_EXECUTABLE
|
||||
? { executablePath: process.env.ARCHY_CHROMIUM_EXECUTABLE }
|
||||
: undefined,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||
<title id="title">GitWorkshop</title>
|
||||
<!-- Official GitWorkshop mark from pinned upstream commit dc36db6, inset to
|
||||
match Archipelago's app-icon safe area. The versioned filename also
|
||||
prevents an older Source prototype icon surviving in browser caches. -->
|
||||
<g transform="translate(13 13) scale(1.59375)">
|
||||
<defs>
|
||||
<clipPath id="gitworkshop-clip-dc36db6">
|
||||
<rect width="64" height="64" rx="16" fill="#fff"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#gitworkshop-clip-dc36db6)">
|
||||
<rect width="64" height="64" rx="16" fill="#9333EA"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M14.999 34.961v-17.96h4v17.96c0 3.172 1.492 6.152 4.015 8.036L28.377 47a14.026 14.026 0 0 1 5.622 11.24V64h-4v-5.76c0-3.17-1.492-6.15-4.015-8.035l-5.363-4.004a14.027 14.027 0 0 1-5.622-11.24Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M48.999 34.961v-17.96h-4v17.96c0 3.172-1.492 6.152-4.015 8.036L35.62 47a14.027 14.027 0 0 0-5.622 11.24V64h4v-5.76c0-3.17 1.492-6.15 4.015-8.035l5.363-4.004A14.027 14.027 0 0 0 49 34.962Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M29.799 64.001V27.887h4.4V64h-4.4Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" d="M40.999 18.001a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm-29.985 0a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm15.985 12a5 5 0 1 1 10 0 5 5 0 0 1-10 0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -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",
|
||||
@@ -353,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user