Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0677924a64 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
33477f284b | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
540639d2c1 | ||
|
|
562871b1ce | ||
|
|
cca3f8bfcd | ||
|
|
89c08be712 | ||
|
|
b4ecf86c13 | ||
|
|
b14fe78306 | ||
|
|
3f0c1038c3 | ||
|
|
1fbefce6df | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 | ||
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac | ||
|
|
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 |
@@ -0,0 +1,31 @@
|
||||
# Blocking incident: Framework LND startup and false zero balance
|
||||
|
||||
Before starting or resuming work in this repository, read
|
||||
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||
|
||||
The user explicitly required this incident to take priority over later work.
|
||||
While its status is OPEN:
|
||||
|
||||
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||
work unless the user explicitly overrides this priority.
|
||||
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||
reboot with access and recovery arrangements in place.
|
||||
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||
|
||||
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||
in effect across sessions until the documented acceptance criteria are met or the
|
||||
user explicitly changes it.
|
||||
|
||||
## Unit tests on a live node
|
||||
|
||||
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
+118
-1
@@ -1,5 +1,122 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.21-alpha (2026-09-30)
|
||||
|
||||
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||
|
||||
## v1.8.20-alpha (2026-09-29)
|
||||
|
||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||
|
||||
## v1.8.19-alpha (2026-09-28)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
|
||||
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
|
||||
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
|
||||
|
||||
## 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.
|
||||
@@ -52,7 +169,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
|
||||
|
||||
|
||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Clean up stale buckets every 5 minutes
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000)
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -57,12 +57,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -70,6 +66,19 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
+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.11-alpha"
|
||||
version = "1.8.21-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.11-alpha"
|
||||
version = "1.8.21-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"
|
||||
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,16 +32,62 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||
use base64::Engine;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
serde_json::json!({
|
||||
"data": data, "data_base64": data,
|
||||
"size": bytes.len(), "size_bytes": bytes.len(),
|
||||
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List content I'm sharing.
|
||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||
@@ -463,17 +509,10 @@ impl RpcHandler {
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
@@ -547,29 +586,27 @@ impl RpcHandler {
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
@@ -583,25 +620,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -609,9 +638,9 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("Peer returned an error ({status}). {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -658,63 +687,21 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||
),
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
@@ -1387,3 +1374,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
use base64::Engine;
|
||||
for paid in [0, 1] {
|
||||
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||
assert_eq!(response["data"], response["data_base64"]);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
[0, 255, 123]
|
||||
);
|
||||
assert_eq!(response["size"], 3);
|
||||
assert_eq!(response["size_bytes"], 3);
|
||||
assert_eq!(response["paid_sats"], paid);
|
||||
assert_eq!(response["owned"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
macaroon_hex: &str,
|
||||
) -> Result<T> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND is unavailable; balance could not be checked")?
|
||||
.error_for_status()
|
||||
.context("LND is not ready; balance could not be checked")?
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned invalid wallet data")
|
||||
}
|
||||
|
||||
fn checked_balances(
|
||||
wallet: LndBalanceResponse,
|
||||
channels: LndChannelBalanceResponse,
|
||||
) -> Result<(i64, i64, i64)> {
|
||||
fn sats(value: Option<String>) -> Result<i64> {
|
||||
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||
Ok(amount)
|
||||
}
|
||||
Ok((
|
||||
sats(wallet.total_balance)?,
|
||||
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||
checked_balances(wallet_balance, channel_balance)?;
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
balance_sats,
|
||||
channel_balance_sats,
|
||||
pending_open_balance,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_balances_are_not_zero() {
|
||||
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||
assert!(checked_balances(
|
||||
serde_json::from_str(body).unwrap(),
|
||||
serde_json::from_str(body).unwrap(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
for value in ["bad", "-1", "9223372036854775808"] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(value.into()),
|
||||
};
|
||||
let channels = serde_json::from_str(
|
||||
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked_balances(wallet, channels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_zero_and_nonzero_balances_survive() {
|
||||
for expected in [0, 42] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(expected.to_string()),
|
||||
};
|
||||
let channels = serde_json::from_value(serde_json::json!({
|
||||
"local_balance":{"sat":expected.to_string()},
|
||||
"pending_open_local_balance":{"sat":"0"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
checked_balances(wallet, channels).unwrap(),
|
||||
(expected, expected, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
stream.read(&mut buf).await.unwrap();
|
||||
let body =
|
||||
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/getinfo"),
|
||||
"test"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
@@ -341,3 +462,44 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||
let progressing = height
|
||||
.zip(last_height)
|
||||
.is_some_and(|(now, before)| now > before);
|
||||
last_height = height;
|
||||
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
// auth.changePassword verifies the existing node password before it
|
||||
// writes either the web hash or the optional Linux/SSH password. This
|
||||
// is safe, actionable validation text; masking it as an internal
|
||||
// failure sent operators to the server logs for a simple typo.
|
||||
"Current password is incorrect",
|
||||
// OTA apply/download errors are all operator-actionable ("download it
|
||||
// again", "download first") — sanitizing them to "Operation failed"
|
||||
// left users stuck with no idea what to do, and hid the "already
|
||||
@@ -242,6 +247,12 @@ mod sanitize_tests {
|
||||
assert_eq!(sanitize_error_message(msg), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_rejection_reaches_the_operator() {
|
||||
let msg = "Current password is incorrect";
|
||||
assert_eq!(sanitize_error_message(msg), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tor_unavailable_precondition_passes_through() {
|
||||
let msg = "Tor address not available. Tor may not be running.";
|
||||
@@ -306,7 +317,7 @@ mod sanitize_tests {
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
/// and the on-disk remember secret (not ephemeral state).
|
||||
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -34,6 +34,7 @@ mod nostr;
|
||||
mod onboarding_gate;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
|
||||
// never added to it — the Phase-10 hard constraint this crate must hold.
|
||||
// The list's *contents* are unchanged; only its read-visibility widens from
|
||||
// "this module" to "this crate".
|
||||
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
|
||||
};
|
||||
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
|
||||
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// Browser apps run on dedicated high ports and can share the authenticated
|
||||
/// node cookie. Nostr signing must therefore be callable by the dashboard
|
||||
/// bridge (ports 80/443), not directly by an iframe that could bypass its
|
||||
/// consent dialog. Requests without Origin remain available to authenticated
|
||||
/// local CLI/integration clients. Development permits loopback origins.
|
||||
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
|
||||
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(url) = reqwest::Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return false;
|
||||
}
|
||||
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
|
||||
return true;
|
||||
}
|
||||
matches!(url.port_or_known_default(), Some(80 | 443))
|
||||
}
|
||||
|
||||
/// Read-only authenticated methods may skip CSRF, but they must still exist in
|
||||
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
|
||||
/// session probe, so keeping the policy in one testable function protects that
|
||||
/// cross-origin app-gate bootstrap contract.
|
||||
fn csrf_exempt_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"node-messages-received"
|
||||
| "server.echo"
|
||||
| "server.get-state"
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
| "system.get-metrics"
|
||||
| "system.get-hostname"
|
||||
)
|
||||
}
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
/// Dev builds only — the pre-setup login bypass that reads this is
|
||||
/// cfg-gated out of release binaries.
|
||||
@@ -291,6 +333,18 @@ impl RpcHandler {
|
||||
|
||||
debug!("RPC method: {}", rpc_req.method);
|
||||
|
||||
if matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node.nostr-sign" | "identity.nostr-sign"
|
||||
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
|
||||
{
|
||||
return Ok(self.error_response(
|
||||
403,
|
||||
"Nostr signing from app origins requires the dashboard consent bridge",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
let mut new_session_cookies: Option<(String, String)> = None;
|
||||
@@ -340,21 +394,7 @@ impl RpcHandler {
|
||||
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
||||
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
||||
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||
let csrf_exempt = matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node-messages-received"
|
||||
| "server.echo"
|
||||
| "server.get-state"
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
| "system.get-metrics"
|
||||
| "system.get-version"
|
||||
);
|
||||
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
|
||||
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
||||
let csrf_header = parts
|
||||
.headers
|
||||
@@ -735,3 +775,62 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nostr_signing_origin_tests {
|
||||
use super::*;
|
||||
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
|
||||
|
||||
fn headers(origin: Option<&str>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
|
||||
assert!(nostr_signing_origin_allowed(&headers(None), false));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local")),
|
||||
false
|
||||
));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("http://192.0.2.10")),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
|
||||
assert!(!nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local:8337")),
|
||||
false
|
||||
));
|
||||
assert!(!nostr_signing_origin_allowed(
|
||||
&headers(Some("https://node.local:7778")),
|
||||
false
|
||||
));
|
||||
assert!(nostr_signing_origin_allowed(
|
||||
&headers(Some("http://localhost:5173")),
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_probe_contract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
|
||||
const PROBE: &str = "system.get-hostname";
|
||||
const DISPATCHER: &str = include_str!("dispatcher.rs");
|
||||
|
||||
assert!(csrf_exempt_method(PROBE));
|
||||
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
|
||||
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
|
||||
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ impl RpcHandler {
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
@@ -199,10 +200,15 @@ impl RpcHandler {
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
|
||||
/// "mint_url": "<optional override>" }`
|
||||
/// "mint_url": "<optional override>",
|
||||
/// "payout_address": "<optional Lightning address>" }`
|
||||
///
|
||||
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
|
||||
/// mint that must be running as an Archy app before calling this endpoint.
|
||||
///
|
||||
/// `payout_address` sets the "owner" identity's Lightning address for
|
||||
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
|
||||
/// Omitted or blank leaves whatever's already on the router untouched.
|
||||
pub(super) async fn handle_openwrt_provision_tollgate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -240,12 +246,35 @@ impl RpcHandler {
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
|
||||
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
|
||||
// mint URL against this value with an exact string compare, and
|
||||
// Cashu wallets (Minibits included) encode mint URLs without a
|
||||
// trailing slash. A stray slash here means every otherwise-valid
|
||||
// token gets rejected as "untrusted mint" — confirmed live against
|
||||
// archy-x250-pa3 2026-09-07 with a manually-entered
|
||||
// "https://mint.minibits.cash/Bitcoin/".
|
||||
let mint_url = p
|
||||
.get("mint_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_mint_url)
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
// `None` (not sent, or sent blank) leaves whatever's already on the
|
||||
// router untouched — see apply_payout_identity's doc comment for why
|
||||
// that matters (an upstream-default placeholder otherwise survives
|
||||
// forever, since nothing else ever writes this field).
|
||||
let payout_address = p
|
||||
.get("payout_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
if let Some(address) = payout_address.as_deref() {
|
||||
tollgate::config::validate_payout_address(address)
|
||||
.context("invalid TollGate payout address")?;
|
||||
}
|
||||
|
||||
let config = TollGateConfig {
|
||||
ssid: "archipelago".to_string(),
|
||||
mint_url,
|
||||
@@ -256,6 +285,7 @@ impl RpcHandler {
|
||||
.unwrap_or(60_000),
|
||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
payout_address,
|
||||
};
|
||||
|
||||
// Blocking SSH session, and provision runs `opkg install` over it —
|
||||
|
||||
@@ -330,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
|
||||
@@ -345,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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -258,6 +326,10 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
@@ -1621,124 +1693,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
|
||||
@@ -2790,6 +2748,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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2801,11 +2764,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 [
|
||||
@@ -2837,6 +2832,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;
|
||||
|
||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
let state = if lower.contains("loading block index") {
|
||||
Some("loading its block index. This can take a while after installation or restart")
|
||||
} else if lower.contains("replaying blocks") {
|
||||
Some("checking saved blocks before startup completes")
|
||||
} else if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -340,3 +344,21 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
@@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
// Older installers and self-update create :local themselves. It
|
||||
// must receive source updates too; treating it as a permanent
|
||||
// manual override silently kept the old LND UI after an OTA.
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
local_image = local_image_compat.clone();
|
||||
}
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// Reuse either local tag only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
@@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||
["latest", "local"]
|
||||
.iter()
|
||||
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||
.find(|image| build_unit(spec, image).render() == unit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||
for tag in ["local", "latest"] {
|
||||
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||
let unit = build_unit(spec, &image).render();
|
||||
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||
}
|
||||
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
assert_eq!(
|
||||
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save a complete purchase without overwriting any existing directory entry.
|
||||
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
fn validate_filename(name: &str) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
&& name.len() <= 255,
|
||||
"Invalid purchased filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
validate_filename(name)?;
|
||||
// Never follow a user-created destination directory symlink.
|
||||
match fs::symlink_metadata(dir).await {
|
||||
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
save_after_direct_result(
|
||||
write_direct(dir, name, bytes).await,
|
||||
dir,
|
||||
name,
|
||||
bytes,
|
||||
fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_after_direct_result<F, Fut>(
|
||||
result: std::io::Result<PathBuf>,
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
match result {
|
||||
Ok(path) => Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||
.await
|
||||
.context("Saving purchase in Files user namespace")
|
||||
}
|
||||
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||
if attempt == 1 {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||
_ => format!("{name} ({attempt})"),
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)
|
||||
.await?;
|
||||
let temp = PendingFile(temp_path);
|
||||
file.write_all(bytes).await?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||
.await?;
|
||||
file.sync_all().await?;
|
||||
for attempt in 1..=100 {
|
||||
let target = dir.join(numbered_name(name, attempt));
|
||||
match fs::hard_link(&temp.0, &target).await {
|
||||
Ok(()) => return Ok(target),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Too many existing copies; purchase cache retained",
|
||||
))
|
||||
}
|
||||
|
||||
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||
dir=$1
|
||||
name=$2
|
||||
expected=$3
|
||||
[ ! -L "$dir" ] || exit 1
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp"
|
||||
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
sync -f -- "$tmp"
|
||||
stem=$name
|
||||
ext=
|
||||
case "$name" in
|
||||
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||
esac
|
||||
n=1
|
||||
while [ "$n" -le 100 ]; do
|
||||
candidate=$name
|
||||
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||
dst="$dir/$candidate"
|
||||
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||
printf '%s' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(&dir)
|
||||
.arg(&name)
|
||||
.arg(bytes.len().to_string())
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Starting Files namespace writer")?;
|
||||
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||
let operation = async {
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace writer failed: {}",
|
||||
output.status
|
||||
);
|
||||
fed.context("Sending purchase bytes to Files")?;
|
||||
let chosen =
|
||||
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||
validate_filename(&chosen)?;
|
||||
anyhow::ensure!(
|
||||
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||
"Files writer returned an unexpected name"
|
||||
);
|
||||
Ok(dir.join(chosen))
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||
.await
|
||||
.context("Files namespace writer timed out")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,3 +343,231 @@ mod tests {
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purchase_write_tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
};
|
||||
|
||||
fn no_temps(dir: &Path) {
|
||||
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".archy-saving")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("song.mp3"), b"original")
|
||||
.await
|
||||
.unwrap();
|
||||
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||
b"original"
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..24u8 {
|
||||
let dir = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let bytes = vec![n; 32768];
|
||||
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||
path
|
||||
}));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for task in tasks {
|
||||
assert!(paths.insert(task.await.unwrap()));
|
||||
}
|
||||
assert_eq!(paths.len(), 24);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||
assert!(dir.path().join("name").is_dir());
|
||||
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
] {
|
||||
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||
}
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for n in 1..=100 {
|
||||
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||
for n in 1..=100 {
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
}
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|dir, name, bytes| async move {
|
||||
assert_eq!(bytes, b"abc");
|
||||
Ok(dir.join(name))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, dir.path().join("a"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("namespace"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::StorageFull.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
expected: usize,
|
||||
) -> std::process::Output {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(dir)
|
||||
.arg(name)
|
||||
.arg(expected.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(bytes).await.unwrap();
|
||||
drop(input);
|
||||
child.wait_with_output().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("Music");
|
||||
let name = "song ' $() ; #.mp3";
|
||||
for n in 1..=2 {
|
||||
let output = run_script(&folder, name, b"abc", 3).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(chosen, numbered_name(name, n));
|
||||
let path = folder.join(chosen);
|
||||
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||
assert_eq!(
|
||||
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
no_temps(&folder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(!dir.path().join("never.bin").exists());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(output.stdout, b"name (3)");
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||
0
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_keep_extensions_and_dotfiles() {
|
||||
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -183,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,
|
||||
@@ -206,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`),
|
||||
@@ -424,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#"
|
||||
@@ -446,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"));
|
||||
|
||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// 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";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
||||
///
|
||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
||||
/// still wants. Rotation calls this instead, so there is no code path from
|
||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
||||
/// as an error the caller reports, never as a wipe.
|
||||
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
match unlock_existing_wallet().await? {
|
||||
true => Ok(()),
|
||||
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,3 +1152,44 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +798,10 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
// Unit tests inject a runtime; they must not restart the host Podman API.
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
return Ok(());
|
||||
@@ -1170,15 +1174,21 @@ impl ReconcileReport {
|
||||
fn cascade_pairs_for_report<'r>(
|
||||
report: &'r ReconcileReport,
|
||||
user_stopped: &std::collections::HashSet<String>,
|
||||
changed_backends: &HashSet<String>,
|
||||
) -> Vec<(&'r str, &'static str)> {
|
||||
let mut pairs = Vec::new();
|
||||
for (backend, action) in &report.actions {
|
||||
if !matches!(
|
||||
action,
|
||||
ReconcileAction::Installed | ReconcileAction::Started
|
||||
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A successful systemctl start can be a no-op after a transient
|
||||
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||
if !changed_backends.contains(backend) {
|
||||
continue;
|
||||
}
|
||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||
let dep_untouched = report
|
||||
.actions
|
||||
@@ -1192,6 +1202,25 @@ fn cascade_pairs_for_report<'r>(
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||
/// changed start timestamp qualifies. A failed observation never does.
|
||||
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(before) = before else {
|
||||
return true;
|
||||
};
|
||||
if before.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if before.id != after.id || before.state != ContainerState::Running {
|
||||
return true;
|
||||
}
|
||||
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AdoptionReport {
|
||||
pub adopted: Vec<String>,
|
||||
@@ -1864,7 +1893,7 @@ impl ProdContainerOrchestrator {
|
||||
// Durable installation record, consulted alongside the perishable
|
||||
// `was_running` snapshot for desired-state recovery below.
|
||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||
let (manifests, container_name_by_app_id): (
|
||||
let (mut manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
@@ -1895,15 +1924,50 @@ impl ProdContainerOrchestrator {
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||
// A running LND container can still be locked after boot; its post-start
|
||||
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||
"lnd" => 1,
|
||||
_ => 2,
|
||||
});
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
let listed_containers = self.runtime.list_containers().await.ok();
|
||||
let present_containers: HashSet<String> = listed_containers
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||
// fail under load while systemd still has a healthy running backend.
|
||||
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||
for lm in &manifests {
|
||||
let id = &lm.manifest.app.id;
|
||||
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => {
|
||||
backend_before.insert(id.clone(), Some(status));
|
||||
}
|
||||
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||
backend_before.insert(id.clone(), None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(backend = %id, error = %err,
|
||||
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||
.await
|
||||
.map(|settings| settings.prune)
|
||||
.unwrap_or(true);
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||
@@ -1943,7 +2007,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
&& bitcoin_pruned
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
@@ -2078,7 +2142,20 @@ impl ProdContainerOrchestrator {
|
||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||
// address behind a running dependent's back — §C "restart lnd after
|
||||
// ANY bitcoin recreate".
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
||||
let mut changed_backends = HashSet::new();
|
||||
for (backend, before) in &backend_before {
|
||||
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||
if backend_instance_changed(before.as_ref(), &after) {
|
||||
changed_backends.insert(backend.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// A user stop during a slow reconcile pass still takes precedence.
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||
// across the restart; skip when a worker is mid-sequence.
|
||||
let lock = crate::app_ops::op_lock(dep);
|
||||
@@ -3217,6 +3294,9 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3591,6 +3671,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
|
||||
@@ -3599,6 +3727,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
|
||||
@@ -3658,6 +3791,17 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||
if storage.prune {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||
);
|
||||
env.push("BITCOIN_PRUNE=1".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
@@ -4614,6 +4758,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
|
||||
@@ -5023,6 +5188,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".
|
||||
@@ -5147,7 +5313,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,
|
||||
@@ -5674,6 +5846,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
|
||||
@@ -5937,6 +6155,48 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
orch.set_data_dir(dir.path().to_path_buf());
|
||||
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
// No preference: existing containers need no new environment flag.
|
||||
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||
assert!(!old
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch
|
||||
.resolve_dynamic_env(&mut old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot honor"));
|
||||
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
current
|
||||
.app
|
||||
.container
|
||||
.custom_args
|
||||
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||
assert!(current
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6208,6 +6468,67 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_cascade_requires_observed_instance_change() {
|
||||
let running = ContainerStatus {
|
||||
id: "container-1".into(),
|
||||
name: "bitcoin-core".into(),
|
||||
state: ContainerState::Running,
|
||||
started_at: Some("start-1".into()),
|
||||
health: None,
|
||||
exit_code: None,
|
||||
image: "bitcoin:1".into(),
|
||||
created: "created-1".into(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
};
|
||||
assert!(!backend_instance_changed(Some(&running), &running));
|
||||
assert!(backend_instance_changed(None, &running));
|
||||
let mut before = running.clone();
|
||||
before.state = ContainerState::Exited;
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.id = "old-container".into();
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.started_at = Some("earlier-start".into());
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before.started_at = None;
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
before.id.clear();
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
let mut after = running.clone();
|
||||
after.state = ContainerState::Exited;
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
after = running.clone();
|
||||
after.id.clear();
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||
let none = HashSet::new();
|
||||
let mut report = ReconcileReport {
|
||||
actions: vec![
|
||||
("bitcoin-core".into(), ReconcileAction::Started),
|
||||
("lnd".into(), ReconcileAction::NoOp),
|
||||
],
|
||||
failures: vec![],
|
||||
};
|
||||
// systemctl start of an already active unit does not move its address.
|
||||
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||
let changed = ["bitcoin-core".into()].into();
|
||||
report.actions[0].1 = ReconcileAction::NoOp;
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&report, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||
use std::collections::HashSet;
|
||||
@@ -6219,6 +6540,7 @@ app:
|
||||
failures: vec![],
|
||||
};
|
||||
let none = HashSet::new();
|
||||
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||
|
||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||
let r = report(vec![
|
||||
@@ -6226,7 +6548,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-knots", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6236,7 +6558,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6245,7 +6567,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::NoOp),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||
|
||||
// Dependent itself (re)started this pass → it already resolved the
|
||||
// fresh address; no cascade.
|
||||
@@ -6253,7 +6575,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::Started),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
|
||||
// User-stopped dependent is never bounced.
|
||||
let r = report(vec![
|
||||
@@ -6261,14 +6583,50 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||
|
||||
// Non-backend recreates don't cascade anything.
|
||||
let r = report(vec![
|
||||
("grafana", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||
rt.set_state("lnd", ContainerState::Exited);
|
||||
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(2000);
|
||||
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||
PathBuf::from(format!("/tmp/{id}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let report = orch.reconcile_all().await;
|
||||
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||
let calls = rt.calls();
|
||||
let bitcoin = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:bitcoin-knots")
|
||||
.unwrap();
|
||||
let lnd = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:lnd")
|
||||
.unwrap();
|
||||
let pull = calls
|
||||
.iter()
|
||||
.position(|c| c.starts_with("pull_image:"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
bitcoin < lnd && lnd < pull,
|
||||
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6672,6 +7030,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());
|
||||
|
||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
||||
pub no_new_privileges: bool,
|
||||
pub cpu_quota: Option<u32>,
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub stop_grace_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "[Container]");
|
||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||
let _ = writeln!(s, "Image={}", self.image);
|
||||
let grace = self
|
||||
.stop_grace_secs
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||
let _ = writeln!(s, "StopTimeout={grace}");
|
||||
// Pull=never: companions are pre-pulled or built. A missing image
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||
// not a systemd start gate.
|
||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||
// existing container may still carry Podman's old 10-second default;
|
||||
// StopTimeout alone only protects containers created after migration.
|
||||
let _ = writeln!(s, "ExecStop=");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||
);
|
||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||
// from saturating the journal. Companions: Always. Backends:
|
||||
// OnFailure (clean stops stay stopped).
|
||||
@@ -525,6 +539,9 @@ impl QuadletUnit {
|
||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||
restart_policy: RestartPolicy::Always,
|
||||
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||
manifest, name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,6 +693,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
return Ok(TEST_UNITS
|
||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||
.clone());
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||
@@ -785,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
@@ -806,10 +834,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// The command waiter must outlive both the container grace and systemd's
|
||||
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||
.max(QUADLET_STOP_TIMEOUT)
|
||||
}
|
||||
|
||||
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||
directive_values(unit_body, "StopTimeout=")
|
||||
.last()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||
}
|
||||
|
||||
async fn systemctl_user_status(
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
return Ok(std::process::ExitStatus::from_raw(0));
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -856,6 +903,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
||||
}
|
||||
|
||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::bail!("Unit tests have no real user service manager");
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -923,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
/// that systemd no longer knows about.
|
||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
let svc = format!("{unit_name}.service");
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let timeout = stop_wait_timeout(unit_name, &body);
|
||||
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||
// rootless podman a generated unit can wedge in "deactivating" while
|
||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||
@@ -930,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||
// blocks reinstall). If the graceful stop times out, escalate to
|
||||
// SIGKILL + reset-failed so teardown always proceeds.
|
||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||
if systemctl_user_status(&["stop", &svc], timeout)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = kill_and_reset_service(&svc).await;
|
||||
}
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
@@ -949,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||
// exists to avoid.
|
||||
let _ = tokio::time::timeout(
|
||||
QUADLET_STOP_TIMEOUT,
|
||||
timeout,
|
||||
Command::new("podman")
|
||||
.args(["rm", "-f", unit_name])
|
||||
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||
.status(),
|
||||
)
|
||||
.await;
|
||||
@@ -960,6 +1014,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
|
||||
/// Is the quadlet-generated service currently active?
|
||||
pub async fn is_active(service: &str) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", service])
|
||||
.status()
|
||||
@@ -973,6 +1030,118 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||
for (name, grace) in [
|
||||
("bitcoin-core", 600),
|
||||
("bitcoin-knots", 600),
|
||||
("lnd", 330),
|
||||
("electrumx", 300),
|
||||
("other", 30),
|
||||
] {
|
||||
let unit = QuadletUnit {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let body = unit.render();
|
||||
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||
assert_eq!(
|
||||
stop_wait_timeout(name, &body),
|
||||
Duration::from_secs(grace + 30)
|
||||
);
|
||||
// Legacy units have no StopTimeout directive yet.
|
||||
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||
let manifest: AppManifest = serde_yaml::from_str(
|
||||
r#"
|
||||
app:
|
||||
id: custom-db
|
||||
name: Custom database
|
||||
version: 1.0.0
|
||||
stop_grace_secs: 900
|
||||
container:
|
||||
image: example/db:1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||
assert_eq!(
|
||||
stop_wait_timeout("custom-db", &unit.render()),
|
||||
Duration::from_secs(930)
|
||||
);
|
||||
assert_eq!(
|
||||
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||
let unit = sample_unit();
|
||||
let new = unit.render();
|
||||
let old = new
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.starts_with("StopTimeout=")
|
||||
&& !line.starts_with("TimeoutStopSec=")
|
||||
&& !line.starts_with("ExecStop=")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!exec_changed(&old, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&old, &new));
|
||||
assert!(!health_cmd_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||
if !generator.exists() {
|
||||
eprintln!(
|
||||
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let unit = QuadletUnit {
|
||||
name: "grace-test".into(),
|
||||
image: "localhost/test:latest".into(),
|
||||
stop_grace_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||
let output = std::process::Command::new(generator)
|
||||
.args(["--user", "--dryrun"])
|
||||
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
let stop = generated
|
||||
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||
.unwrap();
|
||||
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||
assert!(
|
||||
stop < remove,
|
||||
"Legacy container must stop gracefully before removal"
|
||||
);
|
||||
assert!(generated.contains("--stop-timeout 600"));
|
||||
assert!(generated.contains("TimeoutStopSec=615"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -296,6 +296,24 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -307,8 +325,12 @@ pub async fn serve_content(
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
@@ -336,24 +358,6 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
@@ -573,7 +577,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
}
|
||||
|
||||
/// Verify a payment token covers the required amount.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||
//! Missing preference preserves the existing disk-based automatic selection.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct BitcoinStorage {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||
let dir = data_dir.join("settings");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("bitcoin-storage.json");
|
||||
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||
tokio::fs::rename(temporary, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), true).await.unwrap();
|
||||
assert!(load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), false).await.unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), true).await.unwrap();
|
||||
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -2159,20 +2189,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 +2223,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 +2241,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 {
|
||||
|
||||
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||
all_target.extend(&change_denoms);
|
||||
|
||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||||
let swap_result = client
|
||||
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||
.await?;
|
||||
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Verify all mints in the token are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||
}
|
||||
}
|
||||
@@ -1205,6 +1211,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
let mut all_already_redeemed = true;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1216,7 +1223,8 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
@@ -1224,10 +1232,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
return Err(receive_failure(last_reason, all_already_redeemed));
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
@@ -1243,6 +1248,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
Ok(received_total)
|
||||
}
|
||||
|
||||
fn receive_failure(last_reason: Option<String>, all_already_redeemed: bool) -> anyhow::Error {
|
||||
match last_reason {
|
||||
Some(reason) if all_already_redeemed => {
|
||||
anyhow::Error::new(super::mint_client::AlreadyRedeemed)
|
||||
.context(format!("Could not receive this ecash: {reason}"))
|
||||
}
|
||||
Some(reason) => anyhow::anyhow!("Could not receive this ecash: {reason}"),
|
||||
None => anyhow::anyhow!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
|
||||
/// For backwards compatibility during migration period.
|
||||
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
@@ -1288,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
||||
token_str: &str,
|
||||
required_sats: u64,
|
||||
) -> Result<u64> {
|
||||
// Handle legacy tokens
|
||||
let token_str = token_str.trim();
|
||||
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
let amount = token_str
|
||||
.split('_')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
if amount < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
amount,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||||
return Ok(received);
|
||||
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
@@ -1326,52 +1330,45 @@ pub async fn verify_and_receive_payment(
|
||||
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
if token.unit.as_deref().unwrap_or("sat") != "sat" {
|
||||
anyhow::bail!("Payment must be denominated in sats");
|
||||
}
|
||||
// A sale must redeem atomically at one mint. Otherwise a later mint
|
||||
// failure can consume earlier inputs without delivering the purchase.
|
||||
let entry = match token.token.as_slice() {
|
||||
[entry] => entry,
|
||||
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||
};
|
||||
let total = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
|
||||
if total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
total,
|
||||
required_sats
|
||||
);
|
||||
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||
}
|
||||
|
||||
// Verify mints are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||||
}
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||
}
|
||||
|
||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let result = client
|
||||
.swap_at_least(
|
||||
&entry.proofs,
|
||||
&amount_to_denominations(total),
|
||||
required_sats,
|
||||
)
|
||||
.await?;
|
||||
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
// Load after the network call, so an unrelated wallet update during the
|
||||
// swap is not overwritten with a pre-swap snapshot.
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
match client.swap(&entry.proofs, &target_amounts).await {
|
||||
Ok(result) => {
|
||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Payment verification failed: only {} of {} sats verified",
|
||||
received_total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
@@ -1632,6 +1629,18 @@ fn default_mint_url() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn mixed_mint_failures_do_not_discard_a_retryable_claim() {
|
||||
let reason = super::super::mint_client::ALREADY_REDEEMED_MSG.to_string();
|
||||
assert!(super::receive_failure(Some(reason.clone()), true)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(!super::receive_failure(Some(reason), false)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(
|
||||
!super::receive_failure(None, true).is::<super::super::mint_client::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -2443,3 +2452,7 @@ mod tests {
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "payment_tests.rs"]
|
||||
mod payment_tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,10 +71,28 @@ pub struct MintResult {
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
///
|
||||
/// Text of the NUT error-code-11001 translation, exposed so callers that
|
||||
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
|
||||
/// Minibits claim) can recognize an already-spent token as terminal rather
|
||||
/// than retrying it forever.
|
||||
pub const ALREADY_REDEEMED_MSG: &str =
|
||||
"This ecash has already been redeemed — it can't be claimed twice.";
|
||||
|
||||
/// Typed terminal condition: never infer spent proofs from a mint's free text.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AlreadyRedeemed;
|
||||
impl std::fmt::Display for AlreadyRedeemed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(ALREADY_REDEEMED_MSG)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for AlreadyRedeemed {}
|
||||
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11001 => ALREADY_REDEEMED_MSG,
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
@@ -124,8 +142,29 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
|
||||
if serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
|
||||
== Some(11001)
|
||||
{
|
||||
return cause.context(AlreadyRedeemed);
|
||||
}
|
||||
cause.context(describe_mint_error_body(status, body))
|
||||
}
|
||||
|
||||
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||
let mut outputs = Vec::new();
|
||||
for &amount in requested {
|
||||
if available >= amount {
|
||||
outputs.push(amount);
|
||||
available -= amount;
|
||||
} else {
|
||||
outputs.extend(amount_to_denominations(available));
|
||||
break;
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
@@ -487,6 +526,21 @@ impl MintClient {
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
self.swap_at_least(inputs, target_amounts, 0).await
|
||||
}
|
||||
|
||||
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||
/// before consuming any input proofs.
|
||||
pub async fn swap_at_least(
|
||||
&self,
|
||||
inputs: &[Proof],
|
||||
target_amounts: &[u64],
|
||||
minimum: u64,
|
||||
) -> Result<SwapResult> {
|
||||
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||
// files and streams) must expand these, not only wallet imports.
|
||||
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||
let inputs = resolved.as_slice();
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
@@ -494,16 +548,35 @@ impl MintClient {
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||
let inputs_total = inputs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.context("Input amount overflow")?;
|
||||
let keysets = self.get_keysets().await?;
|
||||
let mut fee_ppk = 0u64;
|
||||
for proof in inputs {
|
||||
let input_keyset = keysets
|
||||
.iter()
|
||||
.find(|k| k.id == proof.id)
|
||||
.context("The mint does not recognize an input keyset")?;
|
||||
anyhow::ensure!(
|
||||
input_keyset.unit == "sat",
|
||||
"Input keyset is not denominated in sats"
|
||||
);
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(input_keyset.input_fee_ppk)
|
||||
.context("Mint fee overflow")?;
|
||||
}
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
if spendable < minimum {
|
||||
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
|
||||
}
|
||||
let requested = target_amounts
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
|
||||
.context("Output amount overflow")?;
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
@@ -514,7 +587,10 @@ impl MintClient {
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
// Callers put payment outputs before change. Keep that prefix
|
||||
// intact while fees reduce change; re-splitting the entire sum
|
||||
// can omit a payment denomination after consuming the inputs.
|
||||
owned_targets = fee_adjusted_targets(target_amounts, spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
@@ -559,6 +635,9 @@ impl MintClient {
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
if sig.amount != *amount || sig.id != keyset.id {
|
||||
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||
}
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
@@ -705,43 +784,35 @@ impl MintClient {
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||
/// or ambiguous short IDs are rejected before redemption.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
return Ok(proofs.to_vec());
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
let known = self.get_cdk_keysets().await?;
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
if is_truncated_v2_keyset_id(&p.id) {
|
||||
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||
}
|
||||
p
|
||||
Ok(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -777,7 +848,7 @@ impl MintClient {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint != self.url {
|
||||
if entry.mint.trim_end_matches('/') != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
@@ -788,8 +859,7 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
@@ -803,6 +873,28 @@ impl MintClient {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
|
||||
let spent = super::mint_error(
|
||||
"Swap",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
r#"{"code":11001,"detail":"Token Already Spent"}"#,
|
||||
)
|
||||
.context("Receive failed");
|
||||
assert!(spent.is::<super::AlreadyRedeemed>());
|
||||
let body =
|
||||
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
||||
use super::*;
|
||||
use crate::wallet::{bdhke, cashu::Proof};
|
||||
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const ACTIVE: &str = "0011223344556677";
|
||||
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
||||
|
||||
struct Mint {
|
||||
url: String,
|
||||
requests: Arc<Mutex<Vec<Value>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||
}
|
||||
impl Drop for Mint {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn signing_key() -> SecretKey {
|
||||
SecretKey::from_slice(&[7; 32]).unwrap()
|
||||
}
|
||||
fn signed_point(point: PublicKey) -> String {
|
||||
point
|
||||
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
fn proof(id: &str, amount: u64) -> Proof {
|
||||
let secret = format!("test-{id}-{amount}");
|
||||
Proof {
|
||||
amount,
|
||||
id: id.into(),
|
||||
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
impl Mint {
|
||||
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = requests.clone();
|
||||
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
||||
let rejection = failure.clone();
|
||||
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
||||
let service = make_service_fn(move |_| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
let mut status = 200;
|
||||
let body = match req.uri().path() {
|
||||
"/v1/keysets" => json!({"keysets":[
|
||||
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||
]}),
|
||||
"/v1/keys" => {
|
||||
let public =
|
||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||
.to_string();
|
||||
let keys: serde_json::Map<String, Value> = (0..16)
|
||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||
.collect();
|
||||
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
||||
}
|
||||
"/v1/swap" => {
|
||||
let body: Value = serde_json::from_slice(
|
||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
seen.lock().unwrap().push(body.clone());
|
||||
let inputs = body["inputs"].as_array().unwrap();
|
||||
let outputs = body["outputs"].as_array().unwrap();
|
||||
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if code != 0 {
|
||||
status = code;
|
||||
json!({"detail":"mock mint rejection"})
|
||||
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
||||
{
|
||||
status = 422;
|
||||
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
||||
} else if inputs.iter().any(|p| {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(p["secret"].as_str().unwrap())
|
||||
}) {
|
||||
status = 400;
|
||||
json!({"code":11001,"detail":"Token Already Spent"})
|
||||
} else {
|
||||
let total: u64 =
|
||||
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
let out: u64 =
|
||||
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
assert_eq!(
|
||||
out,
|
||||
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
||||
);
|
||||
for p in inputs {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(p["secret"].as_str().unwrap().into());
|
||||
}
|
||||
json!({"signatures":outputs.iter().map(|o| json!({
|
||||
"amount":o["amount"],"id":ACTIVE,
|
||||
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status = 404;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::from_tcp(listener).unwrap().serve(service);
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
requests,
|
||||
task,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
async fn wallet(&self) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_accepted_mints(
|
||||
dir.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![format!("{}/", self.url)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let decoded = CashuToken::deserialize(&token).unwrap();
|
||||
assert_eq!(
|
||||
decoded.token[0].proofs[0].id.len(),
|
||||
16,
|
||||
"reproduce the short V4 ID"
|
||||
);
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
100
|
||||
);
|
||||
let wallet = load_wallet(dir.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 100);
|
||||
for p in wallet.proofs {
|
||||
assert_eq!(
|
||||
p.proof.c,
|
||||
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
||||
);
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|p| p["id"] == V2));
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v3_full_v2_and_v1_ids_work() {
|
||||
for id in [V2, ACTIVE] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
128
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("after mint fees"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 127)
|
||||
.await
|
||||
.unwrap(),
|
||||
127
|
||||
);
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_mint_response_does_not_credit_wallet() {
|
||||
for status in [200, 400, 422, 500, 503] {
|
||||
let mint = Mint::start(0, Some(status)).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
||||
let mut invalid = vec![
|
||||
"cashuSend_500_abc_1700000000".into(),
|
||||
"cashuBinvalid".into(),
|
||||
];
|
||||
let mut wrong_unit = token.clone();
|
||||
wrong_unit.unit = Some("usd".into());
|
||||
invalid.push(wrong_unit.serialize().unwrap());
|
||||
let mut multi = token.clone();
|
||||
multi.token.push(token.token[0].clone());
|
||||
invalid.push(multi.serialize().unwrap());
|
||||
let mut untrusted = token.clone();
|
||||
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
||||
invalid.push(untrusted.serialize().unwrap());
|
||||
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
||||
invalid.push(
|
||||
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for value in invalid {
|
||||
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
assert!(
|
||||
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
||||
let mint = Mint::start(0, Some(422)).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let seller = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let token = send_token(buyer.path(), 100).await.unwrap();
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(receive_token(buyer.path(), &token).await.is_err());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_mint_does_not_credit_seller() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
mint.task.abort();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
||||
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
||||
// which is needed for a 65-sat payment, after consuming the inputs.
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
let first = proof(V2, 64);
|
||||
let mut second = first.clone();
|
||||
second.secret.push_str("-second");
|
||||
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
||||
wallet.add_proofs(&mint.url, vec![first, second]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
||||
assert_eq!(
|
||||
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
||||
65
|
||||
);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
||||
use crate::content_server::{
|
||||
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
||||
};
|
||||
for (exists, accepts_cashu, price) in [
|
||||
(true, true, 100),
|
||||
(true, false, 100),
|
||||
(false, true, 100),
|
||||
(true, true, 129),
|
||||
] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let seller = mint.wallet().await;
|
||||
let item = ContentItem {
|
||||
id: "paid-test".into(),
|
||||
filename: "test.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 5,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: Availability::AllPeers,
|
||||
access: AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
||||
},
|
||||
};
|
||||
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
||||
.await
|
||||
.unwrap();
|
||||
if exists {
|
||||
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let result = content_server::serve_content(
|
||||
seller.path(),
|
||||
"paid-test",
|
||||
Some(&token),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if exists && accepts_cashu && price <= 128 {
|
||||
match result {
|
||||
ServeResult::Ok(bytes, mime) => {
|
||||
assert_eq!(bytes, b"hello");
|
||||
assert_eq!(mime, "text/plain");
|
||||
}
|
||||
_ => panic!("paid content was not delivered"),
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||
));
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
|
||||
pub min_steps: u32,
|
||||
/// Whether the TollGate service should be running and enabled at boot.
|
||||
pub enabled: bool,
|
||||
/// Operator's own Lightning address for the daemon's built-in payout
|
||||
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
|
||||
/// weight 0.79 in the upstream default). `None` leaves whatever is
|
||||
/// already on the router untouched — which, on a router whose TollGate
|
||||
/// wasn't provisioned through this project, is an unmodified upstream
|
||||
/// placeholder nobody actually controls (confirmed live against
|
||||
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
|
||||
pub payout_address: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TollGateConfig {
|
||||
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
|
||||
step_size_ms: 60_000,
|
||||
min_steps: 1,
|
||||
enabled: true,
|
||||
payout_address: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
|
||||
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
|
||||
/// effect on what the daemon advertises or accepts.
|
||||
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||
router.uci_apply(
|
||||
"tollgate",
|
||||
&[
|
||||
("tollgate.main", "tollgate"),
|
||||
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
|
||||
("tollgate.main.metric", "milliseconds"),
|
||||
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
|
||||
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
|
||||
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
|
||||
("tollgate.main.currency", "sat"),
|
||||
("tollgate.main.mint_url", &cfg.mint_url),
|
||||
],
|
||||
)?;
|
||||
let step_size = cfg.step_size_ms.to_string();
|
||||
let min_steps = cfg.min_steps.to_string();
|
||||
let price_sats = cfg.price_sats.to_string();
|
||||
|
||||
let mut pairs = vec![
|
||||
("tollgate.main", "tollgate"),
|
||||
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
|
||||
("tollgate.main.metric", "milliseconds"),
|
||||
("tollgate.main.step_size", step_size.as_str()),
|
||||
("tollgate.main.min_steps", min_steps.as_str()),
|
||||
("tollgate.main.price_per_step", price_sats.as_str()),
|
||||
("tollgate.main.currency", "sat"),
|
||||
("tollgate.main.mint_url", &cfg.mint_url),
|
||||
];
|
||||
// Status-display only (see doc comment above) — only written when the
|
||||
// caller actually supplied one, so a reconfigure that doesn't touch
|
||||
// payout leaves whatever's already there alone.
|
||||
if let Some(addr) = &cfg.payout_address {
|
||||
pairs.push(("tollgate.main.payout_address", addr));
|
||||
}
|
||||
router.uci_apply("tollgate", &pairs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -97,3 +114,155 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
|
||||
.context("upload /etc/tollgate/config.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the operator's own payout Lightning address in
|
||||
/// `/etc/tollgate/identities.json` — the "owner" entry under
|
||||
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
|
||||
/// the other entries there are revenue-share addresses for the upstream
|
||||
/// project's own maintainers and must never be touched by this function).
|
||||
///
|
||||
/// No-op when `payout_address` is `None` — the UI only sends one when the
|
||||
/// operator has actually filled the field in, so a reconfigure of price/mint
|
||||
/// alone never overwrites this. Merges into whatever identities.json already
|
||||
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
|
||||
/// the merchant's own private key and must survive untouched); creates an
|
||||
/// "owner" entry if none exists yet rather than erroring, since a router
|
||||
/// whose TollGate wasn't provisioned through this project may have any
|
||||
/// upstream-default shape here.
|
||||
///
|
||||
/// Must run before the daemon restart in `restart_services` — like
|
||||
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
|
||||
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
|
||||
let Some(address) = payout_address else {
|
||||
return Ok(());
|
||||
};
|
||||
validate_payout_address(address)?;
|
||||
|
||||
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
|
||||
let mut doc = parse_identities(&existing)?;
|
||||
|
||||
merge_payout_identity(&mut doc, address)?;
|
||||
|
||||
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
|
||||
router
|
||||
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
|
||||
.context("upload /etc/tollgate/identities.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_identities(existing: &str) -> Result<serde_json::Value> {
|
||||
serde_json::from_str(existing.trim()).context(
|
||||
"parse existing /etc/tollgate/identities.json; refusing to overwrite malformed identity data",
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject malformed values before provisioning changes anything on the
|
||||
/// router. A payout typo otherwise remains dormant until the threshold is
|
||||
/// reached, when the operator discovers that settlement cannot resolve.
|
||||
pub fn validate_payout_address(address: &str) -> Result<()> {
|
||||
let (name, domain) = address
|
||||
.split_once('@')
|
||||
.context("Lightning address must look like name@example.com")?;
|
||||
if name.is_empty()
|
||||
|| domain.is_empty()
|
||||
|| domain.contains('@')
|
||||
|| address.chars().any(char::is_whitespace)
|
||||
{
|
||||
anyhow::bail!("Lightning address must look like name@example.com");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_payout_identity(doc: &mut serde_json::Value, address: &str) -> Result<()> {
|
||||
let identities = doc
|
||||
.as_object_mut()
|
||||
.context("identities.json root is not a JSON object")?
|
||||
.entry("public_identities")
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
let identities = identities
|
||||
.as_array_mut()
|
||||
.context("identities.json public_identities is not an array")?;
|
||||
|
||||
match identities
|
||||
.iter_mut()
|
||||
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
|
||||
{
|
||||
Some(owner) => {
|
||||
owner["lightning_address"] = serde_json::json!(address);
|
||||
}
|
||||
None => {
|
||||
identities.push(serde_json::json!({
|
||||
"name": "owner",
|
||||
"pubkey": "not currently used",
|
||||
"lightning_address": address,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{merge_payout_identity, parse_identities, validate_payout_address};
|
||||
|
||||
#[test]
|
||||
fn payout_merge_changes_only_owner_address() {
|
||||
let mut doc = serde_json::json!({
|
||||
"config_version": "v0.0.1",
|
||||
"owned_identities": [{ "name": "merchant", "privatekey": "keep-secret" }],
|
||||
"public_identities": [
|
||||
{ "name": "owner", "pubkey": "not currently used", "lightning_address": "old@example.com" },
|
||||
{ "name": "upstream", "lightning_address": "keep@example.com" }
|
||||
]
|
||||
});
|
||||
let before_owned = doc["owned_identities"].clone();
|
||||
let before_other = doc["public_identities"][1].clone();
|
||||
|
||||
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
|
||||
|
||||
assert_eq!(doc["owned_identities"], before_owned);
|
||||
assert_eq!(doc["public_identities"][1], before_other);
|
||||
assert_eq!(
|
||||
doc["public_identities"][0]["lightning_address"],
|
||||
"operator@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payout_merge_can_create_missing_owner() {
|
||||
let mut doc = serde_json::json!({ "public_identities": [] });
|
||||
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
|
||||
assert_eq!(doc["public_identities"][0]["name"], "owner");
|
||||
assert_eq!(
|
||||
doc["public_identities"][0]["lightning_address"],
|
||||
"operator@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payout_address_validation_rejects_typographical_failures() {
|
||||
assert!(validate_payout_address("operator@example.com").is_ok());
|
||||
for invalid in [
|
||||
"",
|
||||
"operator",
|
||||
"@example.com",
|
||||
"operator@",
|
||||
"a@b@c",
|
||||
"a b@example.com",
|
||||
] {
|
||||
assert!(
|
||||
validate_payout_address(invalid).is_err(),
|
||||
"accepted {invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_identity_data_is_never_replaced() {
|
||||
let err = parse_identities("{ truncated").unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("refusing to overwrite malformed identity data"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
||||
|
||||
config::apply(router, config)?;
|
||||
wifi::provision_ssid(router, config)?;
|
||||
// Must come after provision_ssid (creates the `tollgate` network this
|
||||
// folds the upstream installer's own default AP onto) — see
|
||||
// regate_upstream_default_aps for why this is needed at all.
|
||||
wifi::regate_upstream_default_aps(router)
|
||||
.context("re-gate upstream tollgate-module-basic-go default AP(s)")?;
|
||||
// Must come after provision_ssid (which creates br-tollgate) and before
|
||||
// the daemon restart below — config.json is only read at startup.
|
||||
config::apply_daemon_config(router, config)
|
||||
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
|
||||
config::apply_payout_identity(router, config.payout_address.as_deref())
|
||||
.context("write /etc/tollgate/identities.json owner payout address")?;
|
||||
// Also must come after provision_ssid: points gatewayinterface at
|
||||
// br-tollgate, which provision_ssid is what creates.
|
||||
nodogsplash::configure(router, config)
|
||||
|
||||
@@ -118,6 +118,61 @@ fn provision_firewall(router: &Router) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fold the upstream `tollgate-module-basic-go` installer's own default
|
||||
/// AP(s) onto the gated `tollgate` network.
|
||||
///
|
||||
/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot
|
||||
/// scripts itself (no real package manager to trigger them on OpenWrt 25.x —
|
||||
/// see its doc comment). Those upstream scripts rebrand OpenWrt's
|
||||
/// factory-default wifi sections (`wireless.default_radioN`, present on
|
||||
/// every fresh install) to a `TollGate-<serial>` SSID, but only ever touch
|
||||
/// the SSID — they leave `network` at its original `lan` binding. Nothing
|
||||
/// else in this project's own provisioning (`provision_ssid` above) ever
|
||||
/// looks at those sections; it only manages the separate `wireless.tollgate`
|
||||
/// SSID it creates itself. Left alone, the result is two open SSIDs
|
||||
/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's
|
||||
/// (wide open on `lan`, with a direct route to whatever's plugged into the
|
||||
/// wired LAN port).
|
||||
///
|
||||
/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining
|
||||
/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and
|
||||
/// zero NoDogSplash involvement — free, unmetered internet, no captive
|
||||
/// portal, on the router's own admin network.
|
||||
///
|
||||
/// Must run after `provision_network` (needs the `tollgate` network/bridge
|
||||
/// to already exist) and before the network/wifi restart in
|
||||
/// `restart_services` picks the new binding up.
|
||||
pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
|
||||
let sections = router.run_ok(
|
||||
"uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u",
|
||||
)?;
|
||||
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let network_key = format!("{}.network", section);
|
||||
let current = router.uci_get(&network_key).unwrap_or_default();
|
||||
let ssid = router
|
||||
.uci_get(&format!("{}.ssid", section))
|
||||
.unwrap_or_default();
|
||||
// A failed/changed upstream first-boot script can leave a stock
|
||||
// default_radioN section in place. Moving that interface merely
|
||||
// because it is on LAN can seize the router's existing management AP.
|
||||
// Only the public APs the TollGate installer demonstrably rebranded
|
||||
// belong on the paid network.
|
||||
if should_regate_upstream_ap(¤t, &ssid) {
|
||||
info!(
|
||||
"[{}] Re-gating upstream default AP {} ({}) onto the tollgate network",
|
||||
router.host, section, ssid
|
||||
);
|
||||
router.uci_set(&network_key, "tollgate")?;
|
||||
}
|
||||
}
|
||||
router.uci_commit(Some("wireless"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_regate_upstream_ap(network: &str, ssid: &str) -> bool {
|
||||
network.trim() == "lan" && ssid.trim().starts_with("TollGate-")
|
||||
}
|
||||
|
||||
/// Return the first available wireless radio device name (e.g. "radio0").
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
let out =
|
||||
@@ -126,3 +181,19 @@ fn detect_radio(router: &Router) -> Result<String> {
|
||||
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
|
||||
Ok(radio)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_regate_upstream_ap;
|
||||
|
||||
#[test]
|
||||
fn regates_only_confirmed_upstream_tollgate_aps() {
|
||||
assert!(should_regate_upstream_ap("lan", "TollGate-3458"));
|
||||
assert!(should_regate_upstream_ap(" lan\n", " TollGate-A1B2 "));
|
||||
|
||||
assert!(!should_regate_upstream_ap("lan", "OpenWrt"));
|
||||
assert!(!should_regate_upstream_ap("lan", "Archipelago Admin"));
|
||||
assert!(!should_regate_upstream_ap("tollgate", "TollGate-3458"));
|
||||
assert!(!should_regate_upstream_ap("lan", "tollgate-3458"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
FROM docker.io/library/node:24-alpine AS build
|
||||
|
||||
# GitWorkshop has no release artifacts, so pin the audited source revision.
|
||||
# The fetch verifies that the exact requested commit was checked out before any
|
||||
# dependency or build command runs.
|
||||
ARG GITWORKSHOP_COMMIT=dc36db64f6a2cca29d109829eabaf0a49d4bf4da
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /src
|
||||
RUN git init \
|
||||
&& git remote add origin https://github.com/DanConwayDev/gitworkshop.git \
|
||||
&& git fetch --depth=1 origin "${GITWORKSHOP_COMMIT}" \
|
||||
&& git checkout --detach FETCH_HEAD \
|
||||
&& test "$(git rev-parse HEAD)" = "${GITWORKSHOP_COMMIT}"
|
||||
|
||||
COPY gitworkshop-archipelago.patch /tmp/gitworkshop-archipelago.patch
|
||||
RUN git apply --check /tmp/gitworkshop-archipelago.patch \
|
||||
&& git apply /tmp/gitworkshop-archipelago.patch
|
||||
COPY gitworkshop-dependencies.patch /tmp/gitworkshop-dependencies.patch
|
||||
RUN git apply --check /tmp/gitworkshop-dependencies.patch \
|
||||
&& git apply /tmp/gitworkshop-dependencies.patch
|
||||
RUN npm ci \
|
||||
&& npm audit --audit-level=moderate
|
||||
RUN APP_BASE_PATH=/app/archipelago-source/ \
|
||||
APP_RELEASE_VERSION="archipelago-${GITWORKSHOP_COMMIT}" \
|
||||
npm run build
|
||||
|
||||
FROM docker.io/library/nginx:1.27.4-alpine
|
||||
|
||||
COPY --from=build /src/dist/ /usr/share/nginx/html/
|
||||
COPY nginx-main.conf /etc/nginx/nginx.conf
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY UPSTREAM.md /usr/share/doc/archipelago-source/UPSTREAM.md
|
||||
|
||||
# Run both nginx master and workers as the packaged unprivileged user. Writable
|
||||
# runtime paths live on the manifest's small mode-1777 `/tmp` tmpfs, so the
|
||||
# container needs neither Linux capabilities nor a writable root filesystem.
|
||||
EXPOSE 8337
|
||||
ENTRYPOINT []
|
||||
USER nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# GitWorkshop upstream
|
||||
|
||||
This image packages the GitWorkshop NIP-34 web client from:
|
||||
|
||||
- Source: https://github.com/DanConwayDev/gitworkshop
|
||||
- Pinned commit: `dc36db64f6a2cca29d109829eabaf0a49d4bf4da`
|
||||
- Upstream project: https://gitworkshop.dev/
|
||||
- App icon: `public/icons/icon.svg` from the same pinned revision (the artwork
|
||||
is only inset onto Archipelago's standard icon safe area).
|
||||
|
||||
The Archipelago integration patch only makes the upstream Vite/React
|
||||
application work below Archipelago's `/app/archipelago-source/` mount, injects
|
||||
the existing consent-gated Archipelago NIP-07 provider, disables the
|
||||
development-only `localhost:4869` cache-relay probe, and removes two unreachable
|
||||
lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
|
||||
GRASP, repository browser, issue, pull-request, or review interfaces.
|
||||
|
||||
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
|
||||
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
|
||||
install reports zero npm advisories; its type-check, 152 unit tests, and
|
||||
Archipelago subpath production build pass. Keeping this mechanical security
|
||||
update separate makes both the upstream integration and future dependency
|
||||
refreshes auditable.
|
||||
|
||||
The pinned revision and current upstream `main` do not contain a license file,
|
||||
the package metadata declares no license, and GitHub reports no detected
|
||||
license. Archipelago's owner explicitly accepted the resulting redistribution
|
||||
risk on 2026-09-11. This is a project risk decision, not a claim that
|
||||
GitWorkshop is licensed or that downstream recipients receive rights from its
|
||||
copyright holders. An explicit upstream license remains the preferred,
|
||||
auditable resolution.
|
||||
@@ -0,0 +1,418 @@
|
||||
diff --git a/index.html b/index.html
|
||||
index 6894507..a917f5d 100644
|
||||
--- a/index.html
|
||||
+++ b/index.html
|
||||
@@ -14,7 +14,7 @@
|
||||
property="og:description"
|
||||
content="Decentralized GitHub alternative over Nostr"
|
||||
/>
|
||||
- <meta property="og:image" content="/og-image.png" />
|
||||
+ <meta property="og:image" content="%BASE_URL%og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta
|
||||
@@ -22,15 +22,19 @@
|
||||
content="%APP_NAME% — git collaboration without the platform"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
- <meta name="twitter:image" content="/og-image.png" />
|
||||
+ <meta name="twitter:image" content="%BASE_URL%og-image.png" />
|
||||
<meta
|
||||
http-equiv="content-security-policy"
|
||||
- content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
|
||||
+ content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' http: https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
|
||||
/>
|
||||
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
- <link rel="icon" type="image/png" href="/favicon.png" />
|
||||
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
- <link rel="manifest" href="/manifest.webmanifest" />
|
||||
+ <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
+ <link rel="icon" type="image/png" href="%BASE_URL%favicon.png" />
|
||||
+ <link rel="apple-touch-icon" href="%BASE_URL%icons/apple-touch-icon.png" />
|
||||
+ <link
|
||||
+ rel="manifest"
|
||||
+ href="/manifest.webmanifest"
|
||||
+ crossorigin="use-credentials"
|
||||
+ />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -41,7 +45,9 @@
|
||||
background: #16171e;
|
||||
}
|
||||
</style>
|
||||
- <script src="/theme-init.js"></script>
|
||||
+ <script src="%BASE_URL%theme-init.js"></script>
|
||||
+ <script src="%BASE_URL%archipelago-nostrdb-config.js"></script>
|
||||
+ <script data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
@@ -119,7 +125,7 @@
|
||||
<div id="splash-content">
|
||||
<img
|
||||
class="splash-logo"
|
||||
- src="/icons/icon-192x192.png"
|
||||
+ src="%BASE_URL%icons/icon-192x192.png"
|
||||
width="64"
|
||||
height="64"
|
||||
alt=""
|
||||
diff --git a/public/archipelago-nostrdb-config.js b/public/archipelago-nostrdb-config.js
|
||||
new file mode 100644
|
||||
index 0000000..8f598c4
|
||||
--- /dev/null
|
||||
+++ b/public/archipelago-nostrdb-config.js
|
||||
@@ -0,0 +1,7 @@
|
||||
+// window.nostrdb.js probes a developer-only relay at localhost:4869 unless
|
||||
+// configured before the app module graph loads. On an installed node that
|
||||
+// address means the user's own device, can never be the app's cache relay,
|
||||
+// and is correctly blocked by GitWorkshop's production CSP.
|
||||
+window.nostrdbConfig = Object.assign({}, window.nostrdbConfig || {}, {
|
||||
+ localRelays: [],
|
||||
+});
|
||||
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
|
||||
index 0f681e7..16dff02 100644
|
||||
--- a/src/AppRouter.tsx
|
||||
+++ b/src/AppRouter.tsx
|
||||
@@ -20,6 +20,8 @@ import { MaintainerAcceptanceMonitor } from "./components/MaintainerAcceptanceMo
|
||||
import { useRepoPath } from "./hooks/useRepoPath";
|
||||
import { REPO_KIND } from "./lib/nip34";
|
||||
import { getGitWorkshopPath } from "./lib/gitworkshopUrl";
|
||||
+import { useLoginActions } from "./hooks/useLoginActions";
|
||||
+import { accounts } from "./services/accounts";
|
||||
|
||||
/**
|
||||
* Handles public GitWorkshop links in native builds. This stays inside the
|
||||
@@ -365,9 +367,46 @@ function LegacyRedirect() {
|
||||
return <RepoLayout />;
|
||||
}
|
||||
|
||||
+/** Turn an eager node identity choice into GitWorkshop's extension login. */
|
||||
+function ArchipelagoIdentityLogin() {
|
||||
+ const login = useLoginActions();
|
||||
+ const loginRef = useRef(login.extension);
|
||||
+ const loginRunning = useRef(false);
|
||||
+ loginRef.current = login.extension;
|
||||
+
|
||||
+ useEffect(() => {
|
||||
+ const bridge = (
|
||||
+ window as Window & {
|
||||
+ archipelagoNostr?: {
|
||||
+ onIdentitySelected?: (
|
||||
+ callback: (identity: { nostr_pubkey: string }) => void,
|
||||
+ ) => () => void;
|
||||
+ };
|
||||
+ }
|
||||
+ ).archipelagoNostr;
|
||||
+ if (!bridge?.onIdentitySelected) return;
|
||||
+
|
||||
+ return bridge.onIdentitySelected(() => {
|
||||
+ if (accounts.getActive() || loginRunning.current) return;
|
||||
+ loginRunning.current = true;
|
||||
+ void loginRef
|
||||
+ .current()
|
||||
+ .catch((error) => {
|
||||
+ console.error("Archipelago automatic login failed:", error);
|
||||
+ })
|
||||
+ .finally(() => {
|
||||
+ loginRunning.current = false;
|
||||
+ });
|
||||
+ });
|
||||
+ }, []);
|
||||
+
|
||||
+ return null;
|
||||
+}
|
||||
+
|
||||
function AppRouter() {
|
||||
return (
|
||||
- <BrowserRouter>
|
||||
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
+ <ArchipelagoIdentityLogin />
|
||||
<NativeGitWorkshopLinks />
|
||||
<NativeAndroidBackButton />
|
||||
<ScrollToTop />
|
||||
diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx
|
||||
index 3eb76d2..22b079b 100644
|
||||
--- a/src/components/AppFooter.tsx
|
||||
+++ b/src/components/AppFooter.tsx
|
||||
@@ -62,7 +62,7 @@ export function AppFooter() {
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity w-fit"
|
||||
>
|
||||
<img
|
||||
- src="/icons/icon.svg"
|
||||
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
|
||||
alt="GitWorkshop"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
|
||||
index b31aa79..cec9d39 100644
|
||||
--- a/src/components/AppHeader.tsx
|
||||
+++ b/src/components/AppHeader.tsx
|
||||
@@ -155,7 +155,11 @@ export function AppHeader() {
|
||||
to="/"
|
||||
className="group transition-opacity hover:opacity-80 shrink-0"
|
||||
>
|
||||
- <img src="/icons/icon.svg" alt="GitWorkshop" className="h-8 w-8" />
|
||||
+ <img
|
||||
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
|
||||
+ alt="GitWorkshop"
|
||||
+ className="h-8 w-8"
|
||||
+ />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
diff --git a/src/main.tsx b/src/main.tsx
|
||||
index 1e4fead..ae3a045 100644
|
||||
--- a/src/main.tsx
|
||||
+++ b/src/main.tsx
|
||||
@@ -10,9 +10,11 @@ import "@fontsource-variable/inter";
|
||||
// itself, so subsequent loads are fully uncontrolled. Capacitor packages local
|
||||
// assets and does not use this web-deployment cleanup worker.
|
||||
if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
|
||||
- navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
- /* ignore — browser may block in certain envs */
|
||||
- });
|
||||
+ navigator.serviceWorker
|
||||
+ .register(`${import.meta.env.BASE_URL}sw.js`)
|
||||
+ .catch(() => {
|
||||
+ /* ignore — browser may block in certain envs */
|
||||
+ });
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
|
||||
index 18e3593..685c422 100644
|
||||
--- a/src/pages/NotFound.tsx
|
||||
+++ b/src/pages/NotFound.tsx
|
||||
@@ -28,7 +28,7 @@ const NotFound = () => {
|
||||
Oops! Page not found
|
||||
</p>
|
||||
<a
|
||||
- href="/"
|
||||
+ href={import.meta.env.BASE_URL}
|
||||
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Return to Home
|
||||
diff --git a/src/services/settings.ts b/src/services/settings.ts
|
||||
index 8f9a1a7..5438a72 100644
|
||||
--- a/src/services/settings.ts
|
||||
+++ b/src/services/settings.ts
|
||||
@@ -124,8 +124,6 @@ export const fallbackRelaysCustomised$ = isCustomised$(
|
||||
* These are used by the event loaders to find events more efficiently.
|
||||
*/
|
||||
export const DEFAULT_LOOKUP_RELAYS = normalizeRelayList([
|
||||
- "wss://purplepag.es",
|
||||
- "wss://index.hzrd149.com",
|
||||
"wss://indexer.coracle.social",
|
||||
]);
|
||||
|
||||
diff --git a/vite.config.ts b/vite.config.ts
|
||||
index 0534fb9..d94fc70 100644
|
||||
--- a/vite.config.ts
|
||||
+++ b/vite.config.ts
|
||||
@@ -39,37 +39,38 @@ function htmlAppNamePlugin(): Plugin {
|
||||
*/
|
||||
function manifestPlugin(): Plugin {
|
||||
const virtualId = "/manifest.webmanifest";
|
||||
+ const appBase = process.env.APP_BASE_PATH ?? "/";
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
name: "GitWorkshop.dev",
|
||||
short_name: "GitWorkshop",
|
||||
description: "Decentralized GitHub alternative over Nostr",
|
||||
- start_url: "/",
|
||||
+ start_url: appBase,
|
||||
display: "standalone",
|
||||
background_color: "#16171e",
|
||||
theme_color: "#16171e",
|
||||
categories: ["development", "productivity", "utilities"],
|
||||
icons: [
|
||||
{
|
||||
- src: "/icons/icon-192x192.png",
|
||||
+ src: `${appBase}icons/icon-192x192.png`,
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
- src: "/icons/icon-512x512.png",
|
||||
+ src: `${appBase}icons/icon-512x512.png`,
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
- src: "/icons/pwa-maskable-192x192.png",
|
||||
+ src: `${appBase}icons/pwa-maskable-192x192.png`,
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
- src: "/icons/pwa-maskable-512x512.png",
|
||||
+ src: `${appBase}icons/pwa-maskable-512x512.png`,
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
@@ -82,6 +83,12 @@ function manifestPlugin(): Plugin {
|
||||
|
||||
return {
|
||||
name: "manifest",
|
||||
+ transformIndexHtml(html) {
|
||||
+ return html.replace(
|
||||
+ 'href="/manifest.webmanifest"',
|
||||
+ `href="${appBase}manifest.webmanifest"`,
|
||||
+ );
|
||||
+ },
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === virtualId) {
|
||||
@@ -104,6 +111,10 @@ function manifestPlugin(): Plugin {
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(() => ({
|
||||
+ // Archipelago serves GitWorkshop behind the dashboard origin. Vite's base
|
||||
+ // controls emitted asset URLs while BrowserRouter consumes the same value
|
||||
+ // below, so repository routes remain valid below that mount point.
|
||||
+ base: process.env.APP_BASE_PATH ?? "/",
|
||||
define: {
|
||||
__APP_NAME__: JSON.stringify(name),
|
||||
__APP_RELEASE_VERSION__: JSON.stringify(
|
||||
diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx
|
||||
index f59a7d2..3168910 100644
|
||||
--- a/src/components/auth/AccountSwitcher.tsx
|
||||
+++ b/src/components/auth/AccountSwitcher.tsx
|
||||
@@ -46,7 +46,7 @@ function SignerTypeBadge({ account }: { account: IAccount }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Puzzle className="w-3 h-3" />
|
||||
- Extension
|
||||
+ Extension / Archipelago
|
||||
</span>
|
||||
);
|
||||
if (account instanceof NostrConnectAccount)
|
||||
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx
|
||||
index 11f6716..0bba6a2 100644
|
||||
--- a/src/components/auth/LoginDialog.tsx
|
||||
+++ b/src/components/auth/LoginDialog.tsx
|
||||
@@ -239,9 +239,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
|
||||
try {
|
||||
if (!("nostr" in window)) {
|
||||
throw new Error(
|
||||
- "Nostr extension not found. Please install a NIP-07 extension.",
|
||||
+ "No NIP-07 signer found. Open GitWorkshop through Archipelago or install a browser extension.",
|
||||
);
|
||||
}
|
||||
+ const archipelago = (
|
||||
+ window as Window & {
|
||||
+ archipelagoNostr?: { selectIdentity?: () => Promise<unknown> };
|
||||
+ }
|
||||
+ ).archipelagoNostr;
|
||||
+ if (archipelago?.selectIdentity) await archipelago.selectIdentity();
|
||||
await login.extension();
|
||||
onLogin();
|
||||
onClose();
|
||||
@@ -437,7 +443,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Puzzle className="w-4 h-4" />
|
||||
- {isLoading ? "Logging in..." : "Log in with Extension"}
|
||||
+ {isLoading
|
||||
+ ? "Logging in..."
|
||||
+ : "Log in with Extension / Archipelago"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
|
||||
index b8de377..7f72f31 100644
|
||||
--- a/src/pages/Dashboard.tsx
|
||||
+++ b/src/pages/Dashboard.tsx
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ChevronUp,
|
||||
Pin,
|
||||
Search,
|
||||
+ Globe2,
|
||||
} from "lucide-react";
|
||||
import { CreateRepoDialog } from "@/components/CreateRepoDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +37,7 @@ import { useUserActivity } from "@/hooks/useUserActivity";
|
||||
import { useUserRepositories } from "@/hooks/useUserRepositories";
|
||||
import { useUserFollowedRepos } from "@/hooks/useUserFollowedRepos";
|
||||
import { useUserPinnedCoords } from "@/hooks/useUserPinnedRepos";
|
||||
+import { useRepositorySearch } from "@/hooks/useRepositorySearch";
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { useUserProfileSubscription } from "@/hooks/useUserProfileSubscription";
|
||||
import { useUserPath } from "@/hooks/useUserPath";
|
||||
@@ -409,6 +411,63 @@ function FollowedReposPanel({ pubkey }: { pubkey: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
+// ---------------------------------------------------------------------------
|
||||
+// Recent repositories from the wider Nostr network
|
||||
+// ---------------------------------------------------------------------------
|
||||
+
|
||||
+function NetworkRepositoriesPanel() {
|
||||
+ const { repos, isLoading } = useRepositorySearch("");
|
||||
+ const recent = repos?.slice(0, 6);
|
||||
+
|
||||
+ return (
|
||||
+ <div className="h-fit">
|
||||
+ <div className="pb-3 flex items-center justify-between gap-3">
|
||||
+ <h3 className="text-base font-semibold flex items-center gap-2">
|
||||
+ <Globe2 className="h-4 w-4 text-muted-foreground" />
|
||||
+ Nostr network
|
||||
+ </h3>
|
||||
+ <Button
|
||||
+ variant="ghost"
|
||||
+ size="sm"
|
||||
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
+ asChild
|
||||
+ >
|
||||
+ <Link to="/search">
|
||||
+ Browse all
|
||||
+ <ArrowRight className="h-3 w-3 ml-1" />
|
||||
+ </Link>
|
||||
+ </Button>
|
||||
+ </div>
|
||||
+
|
||||
+ {recent === undefined || (isLoading && recent.length === 0) ? (
|
||||
+ <div className="space-y-1">
|
||||
+ {Array.from({ length: 5 }).map((_, i) => (
|
||||
+ <RepoRowSkeleton key={i} />
|
||||
+ ))}
|
||||
+ </div>
|
||||
+ ) : recent.length > 0 ? (
|
||||
+ <div className="space-y-0.5">
|
||||
+ {recent.map((repo) => (
|
||||
+ <RepoListItem
|
||||
+ key={`${repo.selectedMaintainer}:${repo.dTag}`}
|
||||
+ repo={repo}
|
||||
+ />
|
||||
+ ))}
|
||||
+ </div>
|
||||
+ ) : (
|
||||
+ <div className="py-6 text-center">
|
||||
+ <p className="text-sm text-muted-foreground">
|
||||
+ No network repositories available
|
||||
+ </p>
|
||||
+ <p className="text-xs text-muted-foreground/60 mt-1">
|
||||
+ Check the git index relay in Settings
|
||||
+ </p>
|
||||
+ </div>
|
||||
+ )}
|
||||
+ </div>
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded notifications panel (compact, inbox only, max 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -591,6 +650,8 @@ export function Dashboard() {
|
||||
<MyRepositoriesPanel pubkey={pubkey} />
|
||||
<Separator className="opacity-40" />
|
||||
<FollowedReposPanel pubkey={pubkey} />
|
||||
+ <Separator className="opacity-40" />
|
||||
+ <NetworkRepositoriesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
worker_processes auto;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr notice;
|
||||
|
||||
events {
|
||||
worker_connections 256;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
# Host networking is required for the loopback-only Archipelago RPC.
|
||||
# Keep nginx itself on loopback so the authenticated app gate owns every
|
||||
# externally reachable listener.
|
||||
listen 127.0.0.1:8337;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /app/archipelago-source/manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The normal dashboard proxy strips this prefix before forwarding, while
|
||||
# direct app-gate access preserves it. Supporting both keeps health/debug
|
||||
# access useful without making launch depend on any particular interface.
|
||||
location ^~ /app/archipelago-source/ {
|
||||
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
}
|
||||
+60
-18
@@ -989,7 +989,7 @@
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let unit = 'sats';
|
||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let peerSort = { col: 'peer', dir: 1 };
|
||||
let activityFilter = 'all';
|
||||
let logsLoaded = false;
|
||||
@@ -1142,9 +1142,19 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
if (icon) icon.classList.add('animate-spin-slow');
|
||||
try {
|
||||
state.readiness = await lndSafe('/archy-status', null);
|
||||
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
|
||||
state.info = null;
|
||||
state.onchainStale = true;
|
||||
state.chanbalStale = true;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
||||
lndSafe('/v1/getinfo', null),
|
||||
lndSafe('/v1/channels', { channels: [] }),
|
||||
@@ -1166,10 +1176,17 @@
|
||||
state.invoices = (invoices && invoices.invoices) || [];
|
||||
state.txns = (txns && txns.transactions) || [];
|
||||
|
||||
// Balances are separate so one failing endpoint can't blank the rest.
|
||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
||||
// Preserve known balances on outage; never decode an error as zero.
|
||||
const [onchain, chanbal] = await Promise.all([
|
||||
lndSafe('/v1/balance/blockchain', null),
|
||||
lndSafe('/v1/balance/channels', null),
|
||||
]);
|
||||
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
|
||||
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
|
||||
if (!state.onchainStale) state.onchain = onchain;
|
||||
if (!state.chanbalStale) state.chanbal = chanbal;
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
if (icon) icon.classList.remove('animate-spin-slow');
|
||||
}
|
||||
renderAll();
|
||||
@@ -1192,11 +1209,17 @@
|
||||
const pill = document.getElementById('headerStatusPill');
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
|
||||
if (!g) {
|
||||
setText('headerStatusText', 'Unreachable');
|
||||
pill.className = 'pill bad';
|
||||
dot.className = 'status-dot-sm bg-red';
|
||||
document.getElementById('syncCard').style.display = 'none';
|
||||
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||
if (!g || waiting) {
|
||||
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||
pill.className = 'pill warn';
|
||||
dot.className = 'status-dot-sm bg-yellow';
|
||||
document.getElementById('syncCard').style.display = '';
|
||||
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
|
||||
setText('syncBlockLabel', '');
|
||||
setText('syncPercent', '');
|
||||
document.getElementById('syncProgressBar').style.width = '0%';
|
||||
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1237,6 +1260,11 @@
|
||||
}
|
||||
|
||||
// ── Balances ────────────────────────────────────────────────────
|
||||
function validBalance(value) {
|
||||
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function renderBalances() {
|
||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||
@@ -1253,22 +1281,23 @@
|
||||
const haveOnchain = !!state.onchain;
|
||||
const haveChan = !!cb;
|
||||
|
||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
|
||||
setText('liqLocal', fmtAmount(lnLocal));
|
||||
setText('liqRemote', fmtAmount(lnRemote));
|
||||
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
|
||||
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
|
||||
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
|
||||
const total = lnLocal + lnRemote;
|
||||
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
||||
document.getElementById('liqBarLocal').style.width = localPct + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
|
||||
setText('liqHint', total > 0
|
||||
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
|
||||
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
|
||||
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
||||
: 'Open a channel to start sending and receiving over Lightning.');
|
||||
}
|
||||
@@ -1284,6 +1313,15 @@
|
||||
|
||||
function renderSummary() {
|
||||
const g = state.info;
|
||||
if (!g) {
|
||||
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
|
||||
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
|
||||
for (const id of ['healthChain', 'healthGraph']) {
|
||||
const pill = document.getElementById(id);
|
||||
pill.textContent = '—'; pill.className = 'pill warn';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chans = state.channels;
|
||||
const active = chans.filter(c => c.active).length;
|
||||
const inactive = chans.length - active;
|
||||
@@ -1321,6 +1359,10 @@
|
||||
function renderChannels() {
|
||||
const el = document.getElementById('channelList');
|
||||
if (!el) return;
|
||||
if (!state.info) {
|
||||
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
|
||||
return;
|
||||
}
|
||||
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
||||
let list = state.channels.slice();
|
||||
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,47 @@
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Framework incident — closed with operator acceptance
|
||||
|
||||
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||
missing Receive address / false zero balance.** Startup, native balances,
|
||||
Cashu address and source integration were verified; the operator accepted the
|
||||
remaining display check and authorized release. See the incident record for evidence.
|
||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||
and the repository `AGENTS.md` session-start instructions.
|
||||
|
||||
## Next release after 1.8.21 — reported 2026-09-30
|
||||
|
||||
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
||||
and appears underneath the pruning information.** Operator reports white
|
||||
styling with invisible text on the actual kiosk; the same flow works in remote
|
||||
Brave. Reproduce on the X250's kiosk engine and record its version, display
|
||||
scale and resolution. Inspect the native `<select>` in
|
||||
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
|
||||
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
|
||||
not a confirmed cause. Fix contrast and popup visibility without changing
|
||||
version selection or pruning behavior. Validate Core and Knots, open/closed
|
||||
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
|
||||
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
|
||||
alone do not establish that the kiosk rendering is fixed. Track for the next
|
||||
release; the signed 1.8.21 artifacts remain unchanged.
|
||||
|
||||
## Current repair and release tasks — 2026-09-29
|
||||
|
||||
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
||||
|
||||
- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
||||
mint errors, fees, and refund reporting before live validation.
|
||||
- [ ] Complete the remaining Framework incident verification and evidence.
|
||||
- [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
|
||||
migrate the old default with fresh consent and preserve custom/local explorers.
|
||||
- [ ] Offer pruning in the Bitcoin installation version modal, using the same
|
||||
pruning settings as automatic pruning even on large disks.
|
||||
- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
||||
RPC readiness and show install/start/sync waiting states with automatic recovery.
|
||||
- [ ] Test the completed changes on this development box, then publish a new
|
||||
signed OTA and raw ISO release. Record any remaining verification gaps.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
|
||||
|
||||
## Report
|
||||
|
||||
User: "The cashu server is unable to get it's tokens from nostr on
|
||||
[affected node]" — clarified as the Cashu **client wallet**
|
||||
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
|
||||
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
|
||||
|
||||
## Root cause
|
||||
|
||||
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
|
||||
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
|
||||
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
|
||||
token that fails to redeem is kept in `MinibitsState.pending_claims` and
|
||||
retried on the next poll — by design, so a *transient* failure (mint briefly
|
||||
down, decrypt hiccup) never drops real money.
|
||||
|
||||
But one queued claim had already been redeemed (mint error **11001 "Token
|
||||
Already Spent"** — most likely double-delivered by the relay, or redeemed
|
||||
by an earlier run before a crash lost track of it). That's a *terminal*
|
||||
condition, not a transient one: the code didn't distinguish the two, so it
|
||||
retried the same dead claim every ~6 seconds forever:
|
||||
|
||||
```
|
||||
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
|
||||
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
|
||||
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
|
||||
```
|
||||
|
||||
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
|
||||
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
|
||||
`pending_claims` entry. Each poll also unconditionally queried all three
|
||||
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
|
||||
instead of the primary relay only, adding needless churn and leaking the
|
||||
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
|
||||
was additionally failing NIP-42 auth / 503ing on every poll.
|
||||
|
||||
**No funds were at risk** — an already-redeemed token has zero remaining
|
||||
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
|
||||
banner and wasted relay connections.
|
||||
|
||||
### Why this had already been "fixed" once and came back
|
||||
|
||||
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
|
||||
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
|
||||
`489995c`) and pushed to `origin`. **That branch was never merged into
|
||||
`main`.** `main` carries its own, independently-diverged rewrite of
|
||||
`minibits.rs` that never got those two hardening fixes. The affected node
|
||||
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
|
||||
resurfaced on the first replayed/double-delivered claim after that update.
|
||||
|
||||
## Fix
|
||||
|
||||
Two parts:
|
||||
|
||||
### 1. Immediate unstick (affected node, operational, no code change)
|
||||
|
||||
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
|
||||
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
|
||||
state file, restarted the service.
|
||||
- Verified via `journalctl` that polling resumed cleanly with no further
|
||||
"already been redeemed" warnings.
|
||||
|
||||
### 2. Code fix, ported into `main`
|
||||
|
||||
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
|
||||
NUT error-code-11001 translation as a public constant,
|
||||
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
|
||||
only by the structured mint error code. Remote text cannot impersonate it.
|
||||
- **`core/archipelago/src/wallet/minibits.rs`**:
|
||||
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
|
||||
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
|
||||
preserves it only when all failed mint entries report already-spent proofs;
|
||||
mixed terminal/transient failures remain retryable.
|
||||
- In the claim redeem loop, a redeem failure matching
|
||||
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
|
||||
instead of being pushed back onto `pending_claims`. Every other failure
|
||||
still retries next poll, unchanged.
|
||||
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
|
||||
alone first via `try_connect_relay`, and only adds the two public
|
||||
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
|
||||
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
|
||||
backward with an inclusive `until` boundary. The cursor persists across
|
||||
polls when capped or interrupted, independently of the forward watermark.
|
||||
A full same-second boundary is fetched with a larger limit rather than
|
||||
skipped, so multiple payments sharing a timestamp remain reachable.
|
||||
|
||||
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
|
||||
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
|
||||
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
|
||||
than skips — a different but equally valid way to close the same race), and
|
||||
an attempt-count backstop would have required reshaping the `PendingClaim`
|
||||
enum for marginal extra protection beyond what the 11001 fix already covers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo build -p archipelago` — clean, no new warnings.
|
||||
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
|
||||
suite still green (see PR/commit for the run).
|
||||
- Live on the affected node: claim poll loop confirmed quiet post-unstick
|
||||
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
|
||||
|
||||
## Lesson (recorded in memory)
|
||||
|
||||
A fix that lives only on an unmerged feature branch is not a fix that's
|
||||
actually deployed. Before trusting a memory or changelog claim that
|
||||
something "shipped," check which branch the running/released build was
|
||||
built from (`git log <branch>..main` / `main..<branch>`) rather than
|
||||
assuming a pushed branch was merged.
|
||||
|
||||
## Pre-merge review regressions
|
||||
|
||||
- A 450-event newest-first backlog is completely fetched.
|
||||
- 250 distinct payments sharing one timestamp are preserved.
|
||||
- A 1,300-event backlog resumes after the five-page cap and a state reload.
|
||||
- An interrupted relay fetch retains its unfinished cursor.
|
||||
- Only structured error 11001 is terminal, including when errors are wrapped;
|
||||
remote free text and mixed mint failures cannot discard a retryable claim.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Framework: LND startup, missing Receive address, false zero balance
|
||||
|
||||
**Status: CLOSED WITH OPERATOR ACCEPTANCE — startup, native balances, Cashu address and source integration verified; user accepted the remaining display check and authorized release on 2026-09-30.**
|
||||
|
||||
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||
The Framework's installed version and exact incident time have not been verified.
|
||||
|
||||
## Mandatory priority across sessions
|
||||
|
||||
The user explicitly requested that this be investigated and fixed on the node
|
||||
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||
Only live verification below, or an explicit user change of priority, clears it.
|
||||
|
||||
## Reported observations
|
||||
|
||||
- Framework stopped showing its Lightning address in Receive.
|
||||
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||
- Manually restarting LND restored operation.
|
||||
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||
operation, or deployment was performed during this offline investigation.
|
||||
- Still clarify whether the restart was a full reboot or management-service
|
||||
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||
address, or the Cashu tab's `@minibits.cash` address.
|
||||
|
||||
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||
The zero display does not establish that any funds were lost. Its relation to
|
||||
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||
|
||||
## Confirmed source findings
|
||||
|
||||
### 1. LND errors can be presented as successful zero balances
|
||||
|
||||
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||
|
||||
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||
can deserialize with every expected field absent instead of rejecting the call.
|
||||
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||
substitute responses with absent balances. HTTP status is not checked here either.
|
||||
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||
preserves prior balances only when the RPC actually rejects.
|
||||
|
||||
This is a confirmed code defect and a plausible explanation for the reported
|
||||
display. It is not proof of the Framework's failure sequence.
|
||||
|
||||
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||
availability explicitly end to end. Never translate unavailable data into a
|
||||
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||
|
||||
### 2. Startup readiness and wallet unlock need live evidence
|
||||
|
||||
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||
- `crash_recovery.rs` can start existing containers directly.
|
||||
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||
and on normal reconciliation of already-running containers. Therefore it is
|
||||
incorrect to conclude that running containers categorically skip unlock.
|
||||
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||
|
||||
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||
|
||||
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||
|
||||
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||
function can delete the LND chain and graph data directories. Its comment assumes
|
||||
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||
|
||||
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||
wallet deletion. Review and disable this destructive fallback before using a
|
||||
modified initialization path as a repair. The existing
|
||||
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||
|
||||
### 4. The missing address must be identified precisely
|
||||
|
||||
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||
tab and response once the user clarifies and the node can be inspected.
|
||||
|
||||
## Next session: live investigation order
|
||||
|
||||
1. Request Framework access and verify node identity without publishing its hostname,
|
||||
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||
before another restart can obscure evidence. Keep raw logs private and redact
|
||||
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||
dependency, refreshed networking/credentials, or masked another failure.
|
||||
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||
deploying or deliberately rebooting the node.
|
||||
|
||||
## Acceptance criteria — all required to close
|
||||
|
||||
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||
- [x] Fix implemented and focused regression tests pass.
|
||||
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||
fresh zero balance; genuine zero remains correct.
|
||||
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||
full reboot, without manually restarting LND.
|
||||
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||
outages show an actionable state and recover without requiring a page reload.
|
||||
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||
|
||||
## Work completed so far
|
||||
|
||||
2026-09-15: source investigation and persistent session-start instructions only.
|
||||
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||
|
||||
## Live evidence captured 2026-09-15
|
||||
|
||||
Access was provided during the same session. Read-only inspection confirmed:
|
||||
|
||||
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||
- The management service's first sequential reconcile pass was occupied by
|
||||
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||
graph sync and two active channels. Both authenticated balance endpoints
|
||||
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||
ecash wallet is present with proofs and an August modification timestamp.
|
||||
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||
which Receive item the user meant before declaring this part repaired.
|
||||
|
||||
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||
|
||||
Candidate changes on `investigate/framework-lnd-startup`:
|
||||
|
||||
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||
balances, and clear the warning after a successful refresh.
|
||||
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||
|
||||
These changes are not yet deployed or verified through a Framework reboot.
|
||||
|
||||
### Candidate validation and staging
|
||||
|
||||
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||
|
||||
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||
- 58 additional reconciliation/update tests passed.
|
||||
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||
- Rust formatting, frontend type checking and production build passed.
|
||||
- Optimized backend build passed in 8m02s.
|
||||
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||
- Private on-node baseline and static channel backup are under
|
||||
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||
because it interrupts all node services. Do not reboot while that is pending.
|
||||
- SSH works through the temporary control socket
|
||||
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||
are authenticated using its existing local macaroon without printing it.
|
||||
|
||||
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||
|
||||
### Authorized deployment and full reboot — 2026-09-15
|
||||
|
||||
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||
approval is no longer pending. Applied the staged backend and dashboard after
|
||||
rechecking both checksums and rollback copies. There were no pending channel
|
||||
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||
|
||||
Live results:
|
||||
|
||||
- A different boot ID confirms a full reboot occurred.
|
||||
- Running backend on disk matches candidate SHA-256
|
||||
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||
restart or interactive unlock was used after this reboot.
|
||||
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||
are identical to the private pre-reboot baseline; both channels are active.
|
||||
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||
- LND container and systemd restart counts are zero after recovery.
|
||||
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||
Home bundle; their bytes match the installed candidate, including the new
|
||||
unavailable-balance notice.
|
||||
- Captured post-reboot management and LND journals in the private local evidence
|
||||
directory. Detailed before/after identity, channel, and balance records remain
|
||||
in the root-only support directory on Framework.
|
||||
|
||||
The user was asked to refresh the dashboard and confirm the originally missing
|
||||
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||
fleet release. The source branch must be integrated before a subsequent release
|
||||
can preserve this fix across the fleet.
|
||||
|
||||
### Cashu Receive follow-up
|
||||
|
||||
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||
“Lightning address unavailable — you can still paste a token below.”
|
||||
|
||||
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||
The existing Minibits handler requires an ecash seed, but setup was available
|
||||
only through the Settings backup screen; Receive hid the actionable cause.
|
||||
|
||||
UI fix commit: `a3b64670`.
|
||||
|
||||
- Receive checks the non-secret seed status when registration fails.
|
||||
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||
component directly in Receive, with import/restore controls excluded from this
|
||||
focused setup screen. Setup derives from the saved node seed when present.
|
||||
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||
and are never emitted to Receive. Receive retries registration after Done.
|
||||
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||
- Ten focused Receive/backup tests and the production UI build passed.
|
||||
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||
setup bundle returned 200 and byte-matched the candidate.
|
||||
- Backed up original Cashu proofs to the root-only support directory as
|
||||
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||
|
||||
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||
After completion, verify saved seed/profile presence, registration success,
|
||||
address display, and unchanged original proofs before closing the incident.
|
||||
|
||||
### Cashu setup completed and verified — 2026-09-15
|
||||
|
||||
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||
independent-seed fallback was implemented or used. The user completed the existing
|
||||
password-verified setup themselves; the assistant did not receive recovery words.
|
||||
|
||||
Read-only node verification confirmed:
|
||||
|
||||
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||
pre-setup copy; every original proof is preserved.
|
||||
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||
and no funded payment test was performed.
|
||||
|
||||
LND automatic startup and native balances were already verified after the full
|
||||
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||
|
||||
Remaining: integrate the tested source branch before the next fleet release;
|
||||
record final human confirmation of the rendered dashboard balance (native balances
|
||||
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||
to repeat already completed checks.
|
||||
|
||||
### Backup copy and layout — 2026-09-15
|
||||
|
||||
At the user's request, shortened the ecash backup explanations and stacked each
|
||||
card section's text and full-width action vertically. Kept the distinction
|
||||
between node-derived and separate phrases, and the warning that a newly created
|
||||
phrase covers future coins rather than existing legacy coins.
|
||||
|
||||
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||
Framework without a restart; served index and backup-component bundle match the
|
||||
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||
protected incident directory. Source integration and final rendered dashboard
|
||||
balance confirmation remain pending as above.
|
||||
|
||||
### LNURL comment-length report — 2026-09-15
|
||||
|
||||
User reports a maximum-comment-length error in some sending wallets. Live
|
||||
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||
contains the address only; Archy's Receive UI does not add a comment. The
|
||||
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||
invoice requests at the advertised minimum amount; no funds were sent.
|
||||
|
||||
The callback did not reproduce the error, including beyond its advertised limit.
|
||||
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||
an empty comment also fails. Need that result before selecting a code fix.
|
||||
The service controls the advertised limit; changing local Receive text or QR
|
||||
cannot raise it for other wallets.
|
||||
|
||||
### Primal Spark: automatic recipient note exceeds the address limit
|
||||
|
||||
User clarified that no comment was entered and the sender is Primal Spark.
|
||||
Checked Framework's management journal over the preceding 20 minutes: no
|
||||
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||
claim polling connected to and disconnected from the relay normally. Historical
|
||||
seed-authentication failures preceded the successful setup already documented.
|
||||
|
||||
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||
while `commentAllowed` is **100**. Description template (address redacted):
|
||||
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||
|
||||
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||
limit with the exact reported error before requesting the callback.
|
||||
|
||||
This identifies a concrete compatibility failure: the address description can
|
||||
become an automatic over-limit comment without the sender typing anything.
|
||||
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||
the payment work, and supplied the same description observed in live metadata.
|
||||
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||
platform/version was not captured. Node logs alone cannot show sender-side
|
||||
validation or requests to the external Minibits callback.
|
||||
|
||||
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||
also shorten its description or raise its advertised comment limit. Archy does
|
||||
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||
|
||||
### Primal workaround confirmed by user
|
||||
|
||||
The user confirmed successful payment after removing the automatic description.
|
||||
The permanent sender-side correction is to leave the recipient comment empty by
|
||||
default and retain receiver metadata only as display text. In Primal Android,
|
||||
remove the assignment of the LNURL description to the draft recipient note in
|
||||
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||
Existing Framework addresses and wallet identities remain unchanged.
|
||||
|
||||
### Can Archy shorten the current address description?
|
||||
|
||||
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||
comment-limit setting. Its public web repository also contains no implementation
|
||||
of the LNURL metadata endpoint or description template.
|
||||
|
||||
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||
to shorten this text was found. Do not send guessed profile-update fields or
|
||||
rename the address to disguise the problem. A Minibits server change could use
|
||||
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||
|
||||
### Source integration confirmed — 2026-09-29
|
||||
|
||||
`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at
|
||||
`540639d2`. The previously tested startup ordering, safe unlock, and unavailable
|
||||
balance fixes are integrated and included in the intervening releases. The
|
||||
earlier “source integration pending” notes above are historical, not current.
|
||||
The user reports no further Framework incidents. Requested final confirmation
|
||||
of rendered balances and Receive; do not mark closed without that response.
|
||||
|
||||
A separate startup failure was observed on the development box today when Core
|
||||
was installed against existing block data: Core made steady replay progress,
|
||||
while LND exited on its short “bitcoind start timeout”. Candidate work defers
|
||||
unlock until authenticated Bitcoin RPC answers, with dependency waiting states
|
||||
in the LND UI. This is not evidence of a new failure on Framework.
|
||||
|
||||
### Operator acceptance and release authorization — 2026-09-30
|
||||
|
||||
After being told that final rendered balance/Receive confirmation remained and
|
||||
SSH access was unavailable, the user replied: “that's fine I believe it'd fixed,
|
||||
please release”. This explicitly accepts proceeding past the remaining human
|
||||
display check. Close this incident with operator acceptance based on the earlier
|
||||
controlled reboot, preserved identity/channels/native balances, working Receive
|
||||
address/payment, source integration, and the user's report of no further issues.
|
||||
No new direct Framework inspection or on-screen verification is claimed today.
|
||||
Reopen investigation if the original startup, Receive, or false-zero symptom
|
||||
recurs; preserve the wallet and channels.
|
||||
+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,358 @@
|
||||
# Repair and release execution — 2026-09-29
|
||||
|
||||
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||
|
||||
User requires all tasks completed and tested on the development box before the
|
||||
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||
|
||||
## Confirmed evidence
|
||||
|
||||
- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and
|
||||
buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both
|
||||
attempted purchases were refunded 100 sats. The old message guessed a mint
|
||||
mismatch without evidence.
|
||||
- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption
|
||||
bypassed that repair. Central swap repair and protocol-level regression tests now pass.
|
||||
- Core installation on dev reused existing chain data. At 17:42 UTC it was
|
||||
advancing through block replay with no Core container restarts. At 17:49 UTC
|
||||
it had connected to peers and started transaction-index synchronization.
|
||||
- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After
|
||||
Core became available LND stayed running and reported waiting for backend sync.
|
||||
- Framework source fix 4237fb5e is already an ancestor of main. Existing live
|
||||
reboot/native balance evidence is in the incident document. Final display
|
||||
confirmation remains pending.
|
||||
|
||||
## Changes under validation
|
||||
|
||||
- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection;
|
||||
single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or
|
||||
unconditional refund claims. Missing content checked before redemption.
|
||||
- mempool.space default; migrate old tx1138 default with fresh consent, retain
|
||||
local explorer priority and custom preferences.
|
||||
- Core/Knots optional pruning on the version modal and app detail install path;
|
||||
persist choice across runtime restarts; use identical 50,000 MiB automatic
|
||||
pruning entrypoint behavior on large and small disks.
|
||||
- Plain Bitcoin block-index startup message; defer LND wallet initialization or
|
||||
unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI
|
||||
waiting states; no partial total displayed as a complete balance.
|
||||
|
||||
## Validation and release gates
|
||||
|
||||
- [x] Final backend regression suite passes (including mock mint HTTP and real
|
||||
curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption).
|
||||
- [x] Initial explorer and pruning modal tests pass: 15 tests.
|
||||
- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across
|
||||
6 disk/choice combinations each. No existing chain pruned for this test.
|
||||
- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass.
|
||||
- [x] Frontend production build and relevant existing wallet tests pass (34
|
||||
focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,120 passed; production build passed. Full release harness and final frontend follow-up passed.
|
||||
- [x] Fault tests and final source review complete.
|
||||
- [x] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||
- [x] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||
- [x] Framework operator acceptance and authorization to release recorded.
|
||||
- [x] Release version/changelog, catalog/image implications, signing prepared.
|
||||
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
|
||||
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||
or claim that arbitrary failures can never happen. Record material gaps before
|
||||
release. Signing keys remain with the user; prepare concrete artifacts first.
|
||||
|
||||
### Further startup findings
|
||||
|
||||
Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during
|
||||
Bitcoin initial sync. Candidate startup now recognizes the already-unlocked
|
||||
state instead of repeating unlock attempts for ten minutes. The health watchdog
|
||||
also now excludes Bitcoin initial sync, warmup, unavailable/stale status and
|
||||
LND height progress from its restart criteria. A later observed `podman restart`
|
||||
was externally initiated; its precise caller has not yet been established, so
|
||||
the watchdog defect is a source finding rather than a confirmed attribution.
|
||||
|
||||
Framework SSH rejected the previously provided login on 2026-09-29. No password
|
||||
was saved and no wallet changes were attempted. The human display-confirmation
|
||||
question remains pending. Do not repeat a Framework reboot to reconfirm old work.
|
||||
|
||||
LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render
|
||||
tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block
|
||||
until sync, and prevent overlapping refreshes.
|
||||
|
||||
### Final source validation
|
||||
|
||||
The final backend suite passed: 1,548 passed, zero failed, four existing ignored
|
||||
live/hardware tests. Includes saved pruning preference, rejecting an old catalog
|
||||
that cannot honor explicit pruning, and all nine paid-Cashu protocol tests.
|
||||
Unsigned candidate catalog passes strict drift and fleet registry trust checks.
|
||||
The release gate caught a missing What's New entry; generated it from the curated
|
||||
changelog and reran the frontend gate/build. No public release has been changed.
|
||||
|
||||
At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container
|
||||
is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin
|
||||
is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues
|
||||
to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its
|
||||
readiness deferral live before declaring this fixed. Do not attribute the Bitcoin
|
||||
exit to a specific actor without evidence.
|
||||
|
||||
### Doctor restart cause established and repaired
|
||||
|
||||
Full system journal identifies container-doctor at 18:23:21 UTC issuing raw
|
||||
`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same
|
||||
script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening.
|
||||
Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep
|
||||
exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener.
|
||||
The raw restart also enforces a short stop timeout and races Quadlet cleanup.
|
||||
|
||||
The repaired check consumes the entire socket snapshot, distinguishes inspection
|
||||
failure from a missing port, and leaves containers running when inspection fails.
|
||||
Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged
|
||||
Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses
|
||||
20,000 socket rows plus mocked service/container commands and passes. Thirty
|
||||
read-only checks of the actual Bitcoin listener pass. Script deployed to dev and
|
||||
Shorty with root-only rollback copies. OTA runtime payload includes scripts/.
|
||||
This evidence supersedes the earlier unknown-caller/unknown-exit attribution.
|
||||
|
||||
### Initial candidate live validation — 18:48 UTC
|
||||
|
||||
Source 0f85f588, optimized backend SHA256
|
||||
84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6,
|
||||
deployed to dev and Shorty with matching hashes and rollback copies. Both
|
||||
management services restarted; wallets/channels were not reset. Old embedded
|
||||
runtime assets restored the old doctor on backend startup; updated the live
|
||||
script AND embedded runtime copy on both nodes. Final OTA will contain the new
|
||||
script directly.
|
||||
|
||||
Authenticated dev readiness transitioned from waiting_start to waiting_sync.
|
||||
Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown
|
||||
balance, and no blocked native LND calls. Screenshot review also caught invented
|
||||
zero capacity/channel counts during waiting: corrected them and the empty-channel
|
||||
recommendation; five UI regression tests now pass.
|
||||
|
||||
Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned
|
||||
the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and
|
||||
two cached downloads charged zero. Temporary seller files/catalog entries removed.
|
||||
The first test runner expected data_base64 while the first-purchase API returns
|
||||
data; cached responses use data_base64. Existing purchase clients only consume
|
||||
data, so a follow-up normalizes both response variants to both fields.
|
||||
|
||||
The optional Files copy failed because FileBrowser owns host paths as mapped UID
|
||||
100000. Follow-up uses its authenticated API with override=false and collision
|
||||
suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved
|
||||
original bytes, and cleaned up. New protocol tests cover folder creation, escaped
|
||||
names, collisions, authentication failure, disk-full, and unavailable service.
|
||||
Full backend suite for these follow-ups is running; do not package the earlier
|
||||
backend as final.
|
||||
|
||||
### Follow-up validation and OTA delivery check
|
||||
|
||||
Paid-response and Files API regressions passed in the full backend run: 1,552
|
||||
passed, zero failed, four existing ignored tests. Live browser waiting checks
|
||||
passed again after removing invented zero capacity and channel counts.
|
||||
|
||||
OTA inspection found that companion image :local (created by old installers and
|
||||
used on dev) bypassed both source-staleness detection and rebuilding. The earlier
|
||||
assumption that build-context detection covered these nodes was incorrect.
|
||||
Follow-up applies the existing source-mtime/stamp checks to both :local and
|
||||
:latest, preserving the existing tag and rebuilding only stale source. Existing
|
||||
image-ID comparison then restarts the UI companion onto the new image. This does
|
||||
not restart LND itself. Regression covers every companion's two local tags; final
|
||||
backend suite is running. Verify the resulting live rebuilt image before release.
|
||||
|
||||
### Test isolation finding — release remains blocked
|
||||
|
||||
The next full run passed 1,552 tests but one existing boot-loop timing test failed.
|
||||
Its output and node logs exposed an independent test defect: MockRuntime tests
|
||||
still invoked real Quadlet service operations and Podman socket recovery. These
|
||||
caused further LND/companion restarts during unrestricted unit runs. They were not
|
||||
a recurrence of the repaired doctor port check. Stopped unrestricted testing;
|
||||
LND has remained running since 19:02:46 UTC during isolated test execution.
|
||||
|
||||
New isolated runner hides live wallets, service buses, container storage and host
|
||||
process IDs, supplies a private network and temporary writable fixture paths,
|
||||
and keeps host filesystems read-only. An independent boundary probe passed.
|
||||
Test-only service helpers use a temporary Quadlet directory and simulated service
|
||||
results; mocked runtimes skip real Podman socket/network provisioning. Host file
|
||||
helpers require the isolated-runner marker and execute inside the namespace
|
||||
instead of escaping through sudo/systemd-run. Release harness and AGENTS now
|
||||
require this runner. Initial isolation trials correctly blocked host operations
|
||||
and exposed fixture permission assumptions; final runner compiles and executes
|
||||
the full suite with those fixture paths isolated. No final pass claimed yet.
|
||||
|
||||
Main dashboard candidate and AIUI build at b634f41a are now deployed on dev; served
|
||||
index SHA matches the build. Live package.versions returns bitcoinPrune=false
|
||||
for Core and Knots, preserving current automatic mode. Existing full chain stays
|
||||
unpruned. Final backend (Files/cached response/legacy UI delivery follow-ups) is
|
||||
not yet deployed; earlier 0f85f588 backend remains live on both nodes.
|
||||
|
||||
Final isolated backend run: **1,553 passed, zero failed, four existing ignored**
|
||||
in 13 seconds after compilation. Boundary probe confirms no host service buses,
|
||||
live wallet data, host process IDs, or external network. Bitcoin/LND start times
|
||||
remained unchanged during isolated execution. Production helpers are unchanged;
|
||||
the namespace-specific command behavior is compiled only into unit tests.
|
||||
Release and ISO gates now use the isolated runner.
|
||||
|
||||
### Final backend deployment and App Store follow-up — 19:36 UTC
|
||||
|
||||
Full release harness passed: static/catalog checks, frontend type-check and
|
||||
1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
|
||||
four existing ignored). Final optimized backend built successfully; SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
|
||||
and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
|
||||
Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
|
||||
|
||||
Actual desktop pruning screenshot exposed horizontal overflow; moved the
|
||||
explanation below the app header. The App Store uses Marketplace.vue, a separate
|
||||
install path from Discover.vue. Its first Install button bypassed the version
|
||||
modal. The browser check therefore sent an unintended Knots install request at
|
||||
19:28 UTC. Core remained running, no Knots container was created, and the full
|
||||
chain was not pruned. Removed only the newly created Knots installed-app record
|
||||
and newly created version config; preserved root-only rollback copies.
|
||||
|
||||
Marketplace now uses the shared version/pruning modal. Added integration tests
|
||||
for both Core and Knots: no install request until confirmation, selected version
|
||||
and pruning forwarded, cancellation sends no install request. Four Marketplace
|
||||
tests pass (three new plus existing refresh check). Further browser checks block
|
||||
package.install requests at their network boundary. Final frontend rebuild and
|
||||
post-fix live checks remain pending. Final paid-file follow-up is still pending.
|
||||
|
||||
### Unsigned release candidate ready — 19:46 UTC
|
||||
|
||||
Final frontend source/build attribution: 3612458e. Production dashboard and AIUI
|
||||
builds passed. Final frontend suite: 1,120 tests across 139 files passed.
|
||||
Desktop 1280px and mobile 390px browser checks passed for the app detail pruning
|
||||
choice and App Store version modal; no horizontal overflow and no installation
|
||||
request. Screenshot review confirms readable controls and explanation. Browser
|
||||
installation requests are blocked during these selection-only checks.
|
||||
|
||||
Final backend SHA above matches both dev and Shorty. A fresh one-sat purchase
|
||||
passed on those exact binaries: correct file bytes, both response field aliases,
|
||||
exact one-sat refund on underpayment, zero-charge cached repeat, and exact Files
|
||||
copy. Temporary seller entries/files and Files test copy removed; transaction
|
||||
audit and owned cache retained. Total net transfer during the two live purchase
|
||||
rounds: two sats from dev to Shorty. Desktop/mobile LND waiting checks passed
|
||||
again on the automatically rebuilt companion. Native Bitcoin and LND stayed up.
|
||||
|
||||
Prepared, unsigned files:
|
||||
- releases/pending/v1.8.20-alpha/app-catalog.json
|
||||
- releases/pending/v1.8.20-alpha/manifest.json
|
||||
|
||||
Staged OTA backend: 64,716,656 bytes, SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Frontend archive: 97,152,297 bytes, SHA256
|
||||
658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802.
|
||||
Artifact sizes/hashes/release notes validate. Checked actual archive: flat
|
||||
layout, readable root permissions, exact doctor/LND UI source bytes, and fresh
|
||||
AIUI attribution. Catalog has zero metadata drift and passes fleet registry trust.
|
||||
|
||||
Remaining: user-local release-root signatures, Framework's final display
|
||||
confirmation, signed publication to git/ngit, then raw ISO build/boot test/signing
|
||||
and publication. No v1.8.20 public release or tag exists yet. Four pre-existing
|
||||
hardware/live tests remain ignored. Bitcoin sync-to-ready recovery is covered
|
||||
by deterministic tests; the live node remains in initial sync. Do not describe
|
||||
these checks as proof against every possible network/payment failure.
|
||||
|
||||
### Signing and release authorization — 2026-09-30
|
||||
|
||||
Both catalog and OTA signatures verify against the pinned release root. Staged
|
||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||
remaining Framework display check and explicitly authorized release. Publication
|
||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||
|
||||
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||
|
||||
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||
|
||||
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||
and command-wait budgets, including existing containers and uninstall fallback.
|
||||
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||
|
||||
Disposable live regression passed: started an Alpine container with its legacy
|
||||
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||
second graceful stop, verified the same container ID and old internal timeout
|
||||
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||
Fixture had no network or wallet mounts and was removed afterward.
|
||||
|
||||
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||
unlock or restart was used for this recovery.
|
||||
|
||||
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||
|
||||
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||
restart. These service restarts preceded the isolated test executable, whose
|
||||
namespace boundaries remain intact.
|
||||
|
||||
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||
followed by successful systemctl start of an already active unit can produce
|
||||
Started without changing Bitcoin. Dependency restarts now require observed
|
||||
container-ID, running-state, or start-time changes. Failed observations remain
|
||||
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||
are re-read after the potentially slow pass. Added runtime-observation and
|
||||
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||
|
||||
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||
|
||||
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||
suite: 1,120 passed; final production type-check/build passed after the last
|
||||
release-note-only edit. Optimized backend built in 13m22s.
|
||||
|
||||
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||
- Backend: 64,748,176 bytes; SHA256
|
||||
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||
- Frontend archive: 97,152,546 bytes; SHA256
|
||||
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||
|
||||
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||
and start times were preserved. Correct generated graceful-stop commands are
|
||||
present before forced removal on both nodes; temporary grace overrides removed.
|
||||
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||
|
||||
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||
|
||||
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||
transfer across all three live payment rounds in this repair session: three sats.
|
||||
|
||||
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||
|
||||
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||
diagnostic logging on both nodes and restarted only management again; native
|
||||
Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
|
||||
still verified. No temporary graceful-stop overrides remain. Catalog signature
|
||||
verifies against the pinned release root; final 1.8.21 artifact validator passes.
|
||||
The candidate is ready for the user's local OTA signing ceremony.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user