Compare commits
161
Commits
be06e1a502
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439b55a236 | ||
|
|
5ab65f7581 | ||
|
|
169bf77de6 | ||
|
|
7c4169867c | ||
|
|
acf544500f | ||
|
|
7d767c8cb0 | ||
|
|
eb3ccfa00b | ||
|
|
eda28c4cd6 | ||
|
|
d69e845216 | ||
|
|
dc962c53b0 | ||
|
|
6ac26f637c | ||
|
|
27d81e956d | ||
|
|
eb39391223 | ||
|
|
b02ba4100d | ||
|
|
3daea6623b | ||
|
|
d42f448e31 | ||
|
|
1566f1bb00 | ||
|
|
0677924a64 | ||
|
|
971d477795 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
33477f284b | ||
|
|
03e38d1ca3 | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 | ||
|
|
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 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f |
@@ -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"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 48
|
versionCode = 52
|
||||||
versionName = "0.5.28"
|
versionName = "0.5.32"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -41,6 +41,17 @@ android {
|
|||||||
enableV1Signing = true
|
enableV1Signing = true
|
||||||
enableV2Signing = 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 {
|
buildTypes {
|
||||||
@@ -51,6 +62,13 @@ android {
|
|||||||
versionNameSuffix = "-debug"
|
versionNameSuffix = "-debug"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
create("uat") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
applicationIdSuffix = ".uat"
|
||||||
|
versionNameSuffix = "-uat"
|
||||||
|
signingConfig = signingConfigs.getByName("uat")
|
||||||
|
matchingFallbacks += listOf("debug")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
@@ -118,8 +136,8 @@ tasks.register<Exec>("buildRustArm64") {
|
|||||||
|
|
||||||
tasks.matching {
|
tasks.matching {
|
||||||
it.name in listOf(
|
it.name in listOf(
|
||||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
|
||||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
|
||||||
)
|
)
|
||||||
}.configureEach { dependsOn("buildRustArm64") }
|
}.configureEach { dependsOn("buildRustArm64") }
|
||||||
|
|
||||||
|
|||||||
@@ -326,8 +326,9 @@ private object KioskWebView {
|
|||||||
private fun injectSafeAreaVars(view: WebView) {
|
private fun injectSafeAreaVars(view: WebView) {
|
||||||
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
||||||
val density = view.resources.displayMetrics.density
|
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 sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
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
|
// 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.
|
// JS round-trip — and the Vue event it dispatches — when nothing changed.
|
||||||
val stamp = "sa:$sat,$sab"
|
val stamp = "sa:$sat,$sab"
|
||||||
@@ -377,7 +378,8 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
private fun injectTopInset(view: WebView) {
|
private fun injectTopInset(view: WebView) {
|
||||||
val insets = view.rootWindowInsets ?: return
|
val insets = view.rootWindowInsets ?: return
|
||||||
val density = view.resources.displayMetrics.density
|
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
|
if (sat <= 0) return
|
||||||
view.evaluateJavascript(
|
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",
|
"ArchipelagoNative",
|
||||||
)
|
)
|
||||||
@@ -1523,6 +1570,11 @@ private fun InAppBrowser(
|
|||||||
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
|
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
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 canGoBack by remember { mutableStateOf(false) }
|
||||||
var canGoForward by remember { mutableStateOf(false) }
|
var canGoForward by remember { mutableStateOf(false) }
|
||||||
// Main-frame load failure — the branded offline screen renders instead of
|
// 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
|
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||||
// served over plain HTTP too — same dead-clipboard trap.
|
// served over plain HTTP too — same dead-clipboard trap.
|
||||||
addClipboardBridge()
|
addClipboardBridge()
|
||||||
|
val appBrowserView = this
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun expectPageTransition() {
|
||||||
|
appBrowserView.post {
|
||||||
|
hasCommittedPage = false
|
||||||
|
loading = true
|
||||||
|
appBrowserView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ArchipelagoSurface",
|
||||||
|
)
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
webChromeClient = object : WebChromeClient() {
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
@@ -1623,7 +1689,7 @@ private fun InAppBrowser(
|
|||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
loading = true
|
loading = !hasCommittedPage
|
||||||
loadError = false
|
loadError = false
|
||||||
view?.let {
|
view?.let {
|
||||||
injectTopInset(it)
|
injectTopInset(it)
|
||||||
@@ -1632,6 +1698,7 @@ private fun InAppBrowser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
|
hasCommittedPage = true
|
||||||
loading = false
|
loading = false
|
||||||
canGoBack = view?.canGoBack() == true
|
canGoBack = view?.canGoBack() == true
|
||||||
canGoForward = view?.canGoForward() == 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(
|
override fun onReceivedError(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
@@ -1732,6 +1807,7 @@ private fun InAppBrowser(
|
|||||||
text = stringResource(R.string.retry),
|
text = stringResource(R.string.retry),
|
||||||
onClick = {
|
onClick = {
|
||||||
loadError = false
|
loadError = false
|
||||||
|
hasCommittedPage = false
|
||||||
loading = true
|
loading = true
|
||||||
browser?.reload()
|
browser?.reload()
|
||||||
},
|
},
|
||||||
|
|||||||
+135
-1
@@ -1,5 +1,139 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.
|
||||||
|
- Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.
|
||||||
|
- Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.
|
||||||
|
- Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.
|
||||||
|
- Preserved Gitea configuration and SSH operation during fresh setup and upgrades.
|
||||||
|
- Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.
|
||||||
|
- Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.
|
||||||
|
- Prevented manifest command arguments containing apostrophes from being corrupted in generated services.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
|
||||||
|
|
||||||
|
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
|
||||||
|
|
||||||
## v1.8.10-alpha (2026-09-02)
|
## v1.8.10-alpha (2026-09-02)
|
||||||
|
|
||||||
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
||||||
@@ -44,7 +178,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.
|
- **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.
|
- **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)
|
## 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
|
The Archipelago OS image is based on Debian and redistributes Debian packages
|
||||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
||||||
required for hardware support); per-package license texts are preserved at
|
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
|
release's notes. Container images offered through the app catalog and mirror
|
||||||
registry remain under their upstream licenses (including GPL/AGPL software
|
registry remain under their upstream licenses (including GPL/AGPL software
|
||||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
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
|
the app catalog. The modified mempool-frontend image is built from
|
||||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,21 @@ Podman containers managed by the Rust backend.
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://vuejs.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
|
## What is here
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
|||||||
|
|
||||||
const rateBuckets = new Map<string, 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(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [key, bucket] of rateBuckets) {
|
for (const [key, bucket] of rateBuckets) {
|
||||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||||
}
|
}
|
||||||
}, 5 * 60_000)
|
}, 5 * 60_000).unref()
|
||||||
|
|
||||||
function getClientIp(req: IncomingMessage): string {
|
function getClientIp(req: IncomingMessage): string {
|
||||||
return req.socket.remoteAddress ?? 'unknown'
|
return req.socket.remoteAddress ?? 'unknown'
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
|||||||
// Only embedded when explicitly requested via ?embedded param
|
// Only embedded when explicitly requested via ?embedded param
|
||||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||||
|
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div
|
<div
|
||||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||||
:class="[]"
|
:class="[]"
|
||||||
:style="isDark
|
:style="isEmbedded
|
||||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
? { background: 'transparent' }
|
||||||
: isEmbedded
|
: isDark
|
||||||
? { background: 'transparent' }
|
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||||
: { backgroundColor: '#f5f4f1' }"
|
: { 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 -->
|
<!-- Desktop layout -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -57,12 +57,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
Archy's wallpaper remains visible through the iframe. */
|
||||||
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. */
|
|
||||||
background-color: #0a0a0a;
|
background-color: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +66,19 @@ html.light body {
|
|||||||
background-color: #faf9f6;
|
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 ===== */
|
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||||
|
|
||||||
@layer components {
|
@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 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.
|
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
|
## Categories
|
||||||
|
|
||||||
money, commerce, data, home, nostr, networking, community, development, l484
|
money, commerce, data, home, nostr, networking, community, development, l484
|
||||||
|
|||||||
+68
-14
@@ -9,19 +9,31 @@
|
|||||||
"description": "Bitcoin documentaries with Nostr identity.",
|
"description": "Bitcoin documentaries with Nostr identity.",
|
||||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
"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": [
|
"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",
|
"id": "alby-hub",
|
||||||
"title": "Alby Hub",
|
"title": "Alby Hub",
|
||||||
@@ -247,6 +259,19 @@
|
|||||||
},
|
},
|
||||||
"tier": "optional"
|
"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",
|
"id": "grafana",
|
||||||
"title": "Grafana",
|
"title": "Grafana",
|
||||||
@@ -353,13 +378,13 @@
|
|||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"title": "Mempool Explorer",
|
"title": "Mempool Explorer",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1-archy1",
|
||||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
"icon": "/assets/img/app-icons/mempool.webp",
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"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",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
@@ -619,6 +644,35 @@
|
|||||||
"/var/lib/archipelago/vaultwarden:/data"
|
"/var/lib/archipelago/vaultwarden:/data"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "angor-indexer",
|
||||||
|
"title": "Angor Indexer",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||||
|
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||||
|
"author": "Angor / Archipelago",
|
||||||
|
"requires": [
|
||||||
|
"Mempool API",
|
||||||
|
"Unpruned Bitcoin"
|
||||||
|
],
|
||||||
|
"category": "money",
|
||||||
|
"tier": "optional",
|
||||||
|
"icon": "/assets/img/app-icons/angor.svg",
|
||||||
|
"repoUrl": "https://github.com/block-core/angor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "angor-relay",
|
||||||
|
"title": "Angor Relay",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
|
||||||
|
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
||||||
|
"author": "Angor / Archipelago",
|
||||||
|
"requires": [],
|
||||||
|
"category": "nostr",
|
||||||
|
"tier": "optional",
|
||||||
|
"icon": "/assets/img/app-icons/angor.svg",
|
||||||
|
"repoUrl": "https://github.com/hoytech/strfry"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
|||||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||||
|
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
|
||||||
|
|
||||||
## Development Ports (Offset: +10000)
|
## 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 |
|
| DID Wallet | http://localhost:18083 |
|
||||||
| Router | http://localhost:18084 |
|
| Router | http://localhost:18084 |
|
||||||
| Meshtastic | http://localhost:14403 |
|
| Meshtastic | http://localhost:14403 |
|
||||||
|
| GitWorkshop | http://localhost:18337 |
|
||||||
|
|
||||||
## Port Conflict Resolution
|
## 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,57 @@
|
|||||||
|
# Angor Indexer
|
||||||
|
|
||||||
|
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
|
||||||
|
backend and Electrum index instead of creating a second blockchain database.
|
||||||
|
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
|
||||||
|
node must show the existing archival-node requirement; it must never silently
|
||||||
|
unprune or replace its Bitcoin data.
|
||||||
|
|
||||||
|
## Connect Angor
|
||||||
|
|
||||||
|
Install **Angor Indexer** in the store. Its API appears under **Services**.
|
||||||
|
In Angor settings, use `http://<node-address>:8998/` as the custom indexer origin.
|
||||||
|
The `/health` endpoint reports readiness against Mempool's indexed block height;
|
||||||
|
it returns 503 while that backend is unavailable. Index building may take time.
|
||||||
|
|
||||||
|
Browser clients require a reachable HTTPS origin with a trusted certificate.
|
||||||
|
Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS
|
||||||
|
origin in Angor. Do not disable browser TLS checks. The API supports both
|
||||||
|
`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies.
|
||||||
|
|
||||||
|
This endpoint intentionally exposes public blockchain queries and transaction
|
||||||
|
broadcast through the app gate without dashboard-cookie login. It has no Bitcoin
|
||||||
|
RPC password, wallet keys, or persistent wallet data. The backend stays on the
|
||||||
|
managed container network; its private port does not become publicly exposed.
|
||||||
|
You can change network access using the node's normal access controls.
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
A relay is optional. Angor can continue using its configured external relays.
|
||||||
|
Install **Angor Relay** separately to host project metadata locally, then add
|
||||||
|
`ws://<node-address>:8091/` in Angor, or a trusted `wss://` proxy origin for browser
|
||||||
|
clients. Its storage and configuration are separate from the node's internal
|
||||||
|
relay; installing or uninstalling it does not change the internal relay.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Build the pinned image with:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container
|
||||||
|
```
|
||||||
|
|
||||||
|
The image runs as UID 101 with a read-only root filesystem and no capabilities.
|
||||||
|
Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so
|
||||||
|
Mempool recreation does not require editing IP addresses or restarting this app.
|
||||||
|
No app-specific Rust installer is required.
|
||||||
|
|
||||||
|
Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md).
|
||||||
|
The unmodified icon comes from [angor.io/images/logo-text.svg](https://angor.io/images/logo-text.svg), retrieved 2026-09-30.
|
||||||
|
|
||||||
|
Tests and release acceptance are recorded in the next-release checklist. The
|
||||||
|
health probe establishes backend availability, not a guarantee that every
|
||||||
|
address query is indexed at the latest Bitcoin tip.
|
||||||
|
|
||||||
|
Install Mempool Explorer first. The declarative `install_prerequisites` check
|
||||||
|
refuses a new adapter installation if its Mempool API component is absent, before
|
||||||
|
creating an installed-app record. It does not install or resync Bitcoin for you.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb
|
||||||
|
COPY nginx.conf /etc/angor-nginx.conf.template
|
||||||
|
COPY entrypoint.sh /usr/local/bin/angor-indexer
|
||||||
|
USER 101:101
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/angor-indexer"]
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
# Resolve through the container runtime's DNS, including after dependency
|
||||||
|
# recreation. Never bake a container IP into the indexer endpoint.
|
||||||
|
DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf)
|
||||||
|
case "$DNS_RESOLVER" in
|
||||||
|
''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac
|
||||||
|
export DNS_RESOLVER
|
||||||
|
envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf
|
||||||
|
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
events { worker_connections 512; }
|
||||||
|
http {
|
||||||
|
access_log off;
|
||||||
|
server_tokens off;
|
||||||
|
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;
|
||||||
|
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
|
||||||
|
upstream mempool_backend {
|
||||||
|
zone mempool_backend 64k;
|
||||||
|
server mempool-api:8999 resolve;
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Authorization "";
|
||||||
|
proxy_set_header Cookie "";
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
add_header Access-Control-Allow-Origin '*' always;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||||
|
add_header Cache-Control 'no-store' always;
|
||||||
|
if ($request_method = OPTIONS) { return 204; }
|
||||||
|
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
|
||||||
|
# surface too, without doubling already-versioned Angor URLs.
|
||||||
|
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
|
||||||
|
location = / {
|
||||||
|
default_type application/json;
|
||||||
|
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
|
||||||
|
}
|
||||||
|
# Readiness checks the indexing backend, not this gateway's process.
|
||||||
|
location = /health {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://mempool_backend/api/v1/blocks/tip/height;
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 500 502 503 504 =503 @waiting;
|
||||||
|
}
|
||||||
|
location @waiting {
|
||||||
|
default_type application/json;
|
||||||
|
return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n';
|
||||||
|
}
|
||||||
|
location ~ ^/api/(v1/)?tx$ {
|
||||||
|
limit_except GET POST { deny all; }
|
||||||
|
proxy_pass http://mempool_backend;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://mempool_backend;
|
||||||
|
}
|
||||||
|
location / { return 404; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
app:
|
||||||
|
id: angor-indexer
|
||||||
|
name: Angor Indexer
|
||||||
|
version: 1.0.1
|
||||||
|
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
|
||||||
|
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
|
||||||
|
address as the custom indexer in Angor settings. A relay is optional and installed
|
||||||
|
separately.
|
||||||
|
category: money
|
||||||
|
install_prerequisites:
|
||||||
|
- mempool-api
|
||||||
|
upstream:
|
||||||
|
kind: github
|
||||||
|
repo: block-core/angor
|
||||||
|
container:
|
||||||
|
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
dependencies:
|
||||||
|
- app_id: mempool-api
|
||||||
|
version: '>=3.0.0'
|
||||||
|
- bitcoin:archival
|
||||||
|
resources:
|
||||||
|
cpu_limit: 1
|
||||||
|
memory_limit: 128Mi
|
||||||
|
disk_limit: 128Mi
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: true
|
||||||
|
no_new_privileges: true
|
||||||
|
user: 101
|
||||||
|
network_policy: isolated
|
||||||
|
ports:
|
||||||
|
- host: 8998
|
||||||
|
container: 8080
|
||||||
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
auth: open
|
||||||
|
auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Angor Indexer API
|
||||||
|
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
|
||||||
|
required for browser clients.
|
||||||
|
type: api
|
||||||
|
port: 8998
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:8080
|
||||||
|
path: /health
|
||||||
|
interval: 30s
|
||||||
|
timeout: 8s
|
||||||
|
retries: 3
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: none
|
||||||
|
sync_required: true
|
||||||
|
pruning_support: false
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/angor.svg
|
||||||
|
tier: optional
|
||||||
|
repo: https://github.com/block-core/angor
|
||||||
|
features:
|
||||||
|
- Angor mainnet API
|
||||||
|
- Reuses existing Mempool indexing
|
||||||
|
- No separate blockchain database
|
||||||
|
- Optional independent relay
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Angor Relay
|
||||||
|
|
||||||
|
Optional standalone strfry relay for Angor's public project metadata. See
|
||||||
|
[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS.
|
||||||
|
|
||||||
|
The gate exposes port 8091 for Nostr clients. strfry validates event signatures;
|
||||||
|
this is a public relay, not a private messaging archive. It mounts only
|
||||||
|
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
|
||||||
|
It never opens, reconfigures or shares the node's internal strfry database.
|
||||||
|
|
||||||
|
The configuration is seeded only when absent, preserving operator changes.
|
||||||
|
Stop the service before making a consistent backup of its event database.
|
||||||
|
Ordinary start/restart/recreation preserves both mounts. Use the standard app
|
||||||
|
lifecycle; do not manually recreate a systemd-managed container.
|
||||||
|
|
||||||
|
## Image provenance
|
||||||
|
|
||||||
|
Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest
|
||||||
|
`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`.
|
||||||
|
The public Angor policy is supplied by this app's own configuration; it does not
|
||||||
|
reuse the internal relay's event whitelist.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
app:
|
||||||
|
id: angor-relay
|
||||||
|
name: Angor Relay
|
||||||
|
version: 1.1.2
|
||||||
|
upstream:
|
||||||
|
kind: github
|
||||||
|
repo: hoytech/strfry
|
||||||
|
description: Optional dedicated Nostr relay for Angor project metadata. Separate
|
||||||
|
storage and access settings keep the node’s internal relay private. Add this service’s
|
||||||
|
address to Angor’s relay settings; use WSS for browser clients.
|
||||||
|
container:
|
||||||
|
image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2
|
||||||
|
pull_policy: if-not-present
|
||||||
|
dependencies:
|
||||||
|
- storage: 5Gi
|
||||||
|
resources:
|
||||||
|
cpu_limit: 1
|
||||||
|
memory_limit: 512Mi
|
||||||
|
disk_limit: 5Gi
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: true
|
||||||
|
no_new_privileges: true
|
||||||
|
seccomp_profile: default
|
||||||
|
network_policy: isolated
|
||||||
|
apparmor_profile: nostr-relay
|
||||||
|
ports:
|
||||||
|
- host: 8091
|
||||||
|
container: 7777
|
||||||
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
auth: open
|
||||||
|
auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/angor-relay
|
||||||
|
target: /app/strfry-db
|
||||||
|
options:
|
||||||
|
- rw
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||||
|
target: /etc/strfry.conf
|
||||||
|
options:
|
||||||
|
- ro
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||||
|
overwrite: false
|
||||||
|
content: |
|
||||||
|
##
|
||||||
|
## Default strfry config
|
||||||
|
##
|
||||||
|
|
||||||
|
# Directory that contains the strfry LMDB database (restart required)
|
||||||
|
db = "./strfry-db/"
|
||||||
|
|
||||||
|
dbParams {
|
||||||
|
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
|
||||||
|
maxreaders = 256
|
||||||
|
|
||||||
|
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
|
||||||
|
mapsize = 10995116277760
|
||||||
|
|
||||||
|
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
|
||||||
|
noReadAhead = false
|
||||||
|
}
|
||||||
|
|
||||||
|
events {
|
||||||
|
# Maximum size of normalised JSON, in bytes
|
||||||
|
maxEventSize = 65536
|
||||||
|
|
||||||
|
# Events newer than this will be rejected
|
||||||
|
rejectEventsNewerThanSeconds = 900
|
||||||
|
|
||||||
|
# Events older than this will be rejected
|
||||||
|
rejectEventsOlderThanSeconds = 94608000
|
||||||
|
|
||||||
|
# Ephemeral events older than this will be rejected
|
||||||
|
rejectEphemeralEventsOlderThanSeconds = 60
|
||||||
|
|
||||||
|
# Ephemeral events will be deleted from the DB when older than this
|
||||||
|
ephemeralEventsLifetimeSeconds = 300
|
||||||
|
|
||||||
|
# Maximum number of tags allowed
|
||||||
|
maxNumTags = 2000
|
||||||
|
|
||||||
|
# Maximum size for tag values, in bytes
|
||||||
|
maxTagValSize = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
relay {
|
||||||
|
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
|
||||||
|
bind = "0.0.0.0"
|
||||||
|
|
||||||
|
# Port to open for the nostr websocket protocol (restart required)
|
||||||
|
port = 7777
|
||||||
|
|
||||||
|
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||||
|
nofiles = 0
|
||||||
|
|
||||||
|
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||||
|
realIpHeader = ""
|
||||||
|
|
||||||
|
info {
|
||||||
|
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
|
||||||
|
name = "Angor Relay"
|
||||||
|
|
||||||
|
# NIP-11: Detailed information about relay, free-form
|
||||||
|
description = "Dedicated public relay for Angor project metadata."
|
||||||
|
|
||||||
|
# NIP-11: Administrative nostr pubkey, for contact purposes
|
||||||
|
pubkey = ""
|
||||||
|
|
||||||
|
# NIP-11: Alternative administrative contact (email, website, etc)
|
||||||
|
contact = ""
|
||||||
|
|
||||||
|
# NIP-11: URL pointing to an image to be used as an icon for the relay
|
||||||
|
icon = ""
|
||||||
|
|
||||||
|
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
|
||||||
|
nips = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
|
||||||
|
maxWebsocketPayloadSize = 131072
|
||||||
|
|
||||||
|
# Maximum number of filters allowed in a REQ
|
||||||
|
maxReqFilterSize = 200
|
||||||
|
|
||||||
|
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
|
||||||
|
autoPingSeconds = 55
|
||||||
|
|
||||||
|
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
|
||||||
|
enableTcpKeepalive = false
|
||||||
|
|
||||||
|
# How much uninterrupted CPU time a REQ query should get during its DB scan
|
||||||
|
queryTimesliceBudgetMicroseconds = 10000
|
||||||
|
|
||||||
|
# Maximum records that can be returned per filter
|
||||||
|
maxFilterLimit = 500
|
||||||
|
|
||||||
|
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
|
||||||
|
maxSubsPerConnection = 20
|
||||||
|
|
||||||
|
writePolicy {
|
||||||
|
# If non-empty, path to an executable script that implements the writePolicy plugin logic
|
||||||
|
plugin = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
compression {
|
||||||
|
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
|
||||||
|
slidingWindow = true
|
||||||
|
}
|
||||||
|
|
||||||
|
logging {
|
||||||
|
# Dump all incoming messages
|
||||||
|
dumpInAll = false
|
||||||
|
|
||||||
|
# Dump all incoming EVENT messages
|
||||||
|
dumpInEvents = false
|
||||||
|
|
||||||
|
# Dump all incoming REQ/CLOSE messages
|
||||||
|
dumpInReqs = false
|
||||||
|
|
||||||
|
# Log performance metrics for initial REQ database scans
|
||||||
|
dbScanPerf = false
|
||||||
|
|
||||||
|
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
|
||||||
|
invalidEvents = true
|
||||||
|
}
|
||||||
|
|
||||||
|
numThreads {
|
||||||
|
# Ingester threads: route incoming requests, validate events/sigs (restart required)
|
||||||
|
ingester = 3
|
||||||
|
|
||||||
|
# reqWorker threads: Handle initial DB scan for events (restart required)
|
||||||
|
reqWorker = 3
|
||||||
|
|
||||||
|
# reqMonitor threads: Handle filtering of new events (restart required)
|
||||||
|
reqMonitor = 3
|
||||||
|
|
||||||
|
# negentropy threads: Handle negentropy protocol messages (restart required)
|
||||||
|
negentropy = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
negentropy {
|
||||||
|
# Support negentropy protocol messages
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Maximum records that sync will process before returning an error
|
||||||
|
maxSyncEvents = 1000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:7777
|
||||||
|
path: /health
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
nostr_integration:
|
||||||
|
relay_type: public
|
||||||
|
monetization_enabled: false
|
||||||
|
category: nostr
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Angor Relay
|
||||||
|
description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your
|
||||||
|
HTTPS domain.
|
||||||
|
type: api
|
||||||
|
port: 8091
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/angor.svg
|
||||||
|
tier: optional
|
||||||
|
repo: https://github.com/hoytech/strfry
|
||||||
|
features:
|
||||||
|
- Angor project metadata
|
||||||
|
- Separate from the node relay
|
||||||
|
- Persistent Nostr event storage
|
||||||
@@ -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:
|
app:
|
||||||
id: archy-mempool-web
|
id: archy-mempool-web
|
||||||
name: 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
|
# 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:
|
# 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.
|
# container.image names our mirror, not the project it was mirrored from.
|
||||||
@@ -12,7 +12,7 @@ app:
|
|||||||
container_name: mempool
|
container_name: mempool
|
||||||
|
|
||||||
container:
|
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
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
|
|
||||||
@@ -45,7 +45,9 @@ app:
|
|||||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||||
endpoint: http://127.0.0.1:8080
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ app:
|
|||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
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";
|
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;
|
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;
|
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
|
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;
|
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
|
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";
|
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;
|
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;
|
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
|
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;
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
app:
|
||||||
|
id: cuprate-ui
|
||||||
|
name: Cuprate UI
|
||||||
|
version: 1.0.0
|
||||||
|
# Built by this project — there is no upstream release feed to watch.
|
||||||
|
upstream:
|
||||||
|
kind: internal
|
||||||
|
description: |
|
||||||
|
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||||
|
inside a container, serves a static status dashboard, and proxies
|
||||||
|
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||||
|
published host port for the container's 18089). No credentials are
|
||||||
|
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||||
|
the nginx.conf is baked into the image and there is no rendered-config
|
||||||
|
bind-mount like bitcoin-ui's.
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/cuprate-ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/cuprate-ui:local
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: cuprate
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 64Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: host
|
||||||
|
|
||||||
|
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||||
|
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||||
|
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||||
|
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||||
|
# not a publish instruction. Without this declaration the gate would have no
|
||||||
|
# idea the port existed: neither protected nor listed as unprotected.
|
||||||
|
ports:
|
||||||
|
- host: 18091
|
||||||
|
container: 18091
|
||||||
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
auth: gated
|
||||||
|
# First-party companion UI: its nginx forwards the node session cookie
|
||||||
|
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||||
|
# strips it and every data call 401s while the page shell renders.
|
||||||
|
session_passthrough: true
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:18091
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/cuprate.svg
|
||||||
|
category: money
|
||||||
|
tier: optional
|
||||||
|
author: Archipelago
|
||||||
|
repo: https://github.com/Cuprate/cuprate
|
||||||
@@ -36,12 +36,26 @@ app:
|
|||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||||
# `pruning` crate exists in the workspace but nothing in this config
|
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||||
# headroom rather than assuming pruning is available.
|
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||||
- storage: 300Gi
|
# when disk is scarce (see the DISK_GB branch in
|
||||||
|
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||||
|
# syncs until the filesystem fills and takes Archipelago down. The
|
||||||
|
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||||
|
# restart and update refuse, and boot reconcile skips, on any node under
|
||||||
|
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||||
|
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||||
|
# replace that gate with the bitcoin-style entrypoint branch.
|
||||||
|
#
|
||||||
|
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||||
|
# (store size display, install pre-checks, docs) must show the number the
|
||||||
|
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||||
|
# value gets refused at a different, unexplained one. Single source of
|
||||||
|
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||||
|
- storage: 450Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 0
|
cpu_limit: 0
|
||||||
@@ -51,7 +65,9 @@ app:
|
|||||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||||
# headroom above the 8GiB cache for the process itself.
|
# headroom above the 8GiB cache for the process itself.
|
||||||
memory_limit: 10Gi
|
memory_limit: 10Gi
|
||||||
disk_limit: 300Gi
|
# Matches the storage dependency above (= the enforced disk floor),
|
||||||
|
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||||
|
disk_limit: 450Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||||
|
|||||||
+25
-10
@@ -15,15 +15,20 @@ app:
|
|||||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
# Preserve repositories, database, keys and configuration during runtime repairs.
|
||||||
|
backup_before_runtime_change: true
|
||||||
|
|
||||||
dependencies:
|
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:
|
resources:
|
||||||
memory_limit: 256Mi
|
memory_limit: 256Mi
|
||||||
disk_limit: 500Mi
|
disk_limit: 50Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
|
||||||
readonly_root: false
|
readonly_root: false
|
||||||
no_new_privileges: false
|
no_new_privileges: false
|
||||||
network_policy: bridge
|
network_policy: bridge
|
||||||
@@ -60,12 +65,29 @@ app:
|
|||||||
target: /etc/gitea
|
target: /etc/gitea
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
|
# Seed a fresh installation with the same origin advertised by the app gate.
|
||||||
|
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
|
||||||
|
overwrite: false
|
||||||
|
content: |
|
||||||
|
[server]
|
||||||
|
DOMAIN = {{HOST_IP}}
|
||||||
|
SSH_DOMAIN = {{HOST_IP}}
|
||||||
|
ROOT_URL = http://{{HOST_IP}}:3001/
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- GITEA__database__DB_TYPE=sqlite3
|
- GITEA__database__DB_TYPE=sqlite3
|
||||||
- GITEA__server__SSH_PORT=2222
|
- GITEA__server__SSH_PORT=2222
|
||||||
- GITEA__server__SSH_LISTEN_PORT=22
|
- GITEA__server__SSH_LISTEN_PORT=22
|
||||||
- GITEA__server__LFS_START_SERVER=true
|
- GITEA__server__LFS_START_SERVER=true
|
||||||
- GITEA__packages__ENABLED=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_USER=true
|
||||||
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
|
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
|
||||||
|
|
||||||
@@ -98,10 +120,3 @@ app:
|
|||||||
- Issue tracking and pull requests
|
- Issue tracking and pull requests
|
||||||
- CI/CD via Gitea Actions
|
- CI/CD via Gitea Actions
|
||||||
- Lightweight SQLite deployment
|
- Lightweight SQLite deployment
|
||||||
|
|
||||||
nginx_proxy:
|
|
||||||
listen: 3000
|
|
||||||
proxy_pass: http://127.0.0.1:3001
|
|
||||||
extra_headers:
|
|
||||||
- proxy_hide_header X-Frame-Options
|
|
||||||
- proxy_hide_header Content-Security-Policy
|
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ app:
|
|||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: indeedhub-net
|
network: indeedhub-net
|
||||||
network_aliases: [api]
|
network_aliases: [api]
|
||||||
# The JWT signing secret is owned here (no backend container owns it); the
|
# The JWT signing secret and stable envelope-encryption root are owned here;
|
||||||
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
|
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
|
||||||
# only consumed here. ensure_generated_secrets no-ops when a file already
|
# and only consumed here. Existing nodes migrate the legacy AES value into
|
||||||
# exists, so live values on .228 are preserved (postgres pw is fixed at
|
# the secret file once, while fresh nodes receive a unique per-node value.
|
||||||
# PGDATA init — regenerating would lock the API out).
|
|
||||||
generated_secrets:
|
generated_secrets:
|
||||||
- name: indeedhub-jwt
|
- name: indeedhub-jwt
|
||||||
kind: hex32
|
kind: hex32
|
||||||
|
- name: indeedhub-aes-master
|
||||||
|
kind: hex16
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: DATABASE_PASSWORD
|
- key: DATABASE_PASSWORD
|
||||||
secret_file: indeedhub-db-password
|
secret_file: indeedhub-db-password
|
||||||
@@ -34,6 +35,8 @@ app:
|
|||||||
secret_file: indeedhub-minio-password
|
secret_file: indeedhub-minio-password
|
||||||
- key: NOSTR_JWT_SECRET
|
- key: NOSTR_JWT_SECRET
|
||||||
secret_file: indeedhub-jwt
|
secret_file: indeedhub-jwt
|
||||||
|
- key: AES_MASTER_SECRET
|
||||||
|
secret_file: indeedhub-aes-master
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: indeedhub-postgres
|
- app_id: indeedhub-postgres
|
||||||
@@ -67,9 +70,6 @@ app:
|
|||||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||||
- S3_PUBLIC_BUCKET_URL=/storage
|
- S3_PUBLIC_BUCKET_URL=/storage
|
||||||
- NOSTR_JWT_EXPIRES_IN=7d
|
- 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
|
- ENVIRONMENT=production
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ app:
|
|||||||
secret_file: indeedhub-db-password
|
secret_file: indeedhub-db-password
|
||||||
- key: AWS_SECRET_KEY
|
- key: AWS_SECRET_KEY
|
||||||
secret_file: indeedhub-minio-password
|
secret_file: indeedhub-minio-password
|
||||||
|
- key: AES_MASTER_SECRET
|
||||||
|
secret_file: indeedhub-aes-master
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: indeedhub-api
|
- app_id: indeedhub-api
|
||||||
@@ -51,4 +53,3 @@ app:
|
|||||||
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ app:
|
|||||||
- copy_from_host:
|
- copy_from_host:
|
||||||
src: "web-ui/nostr-provider.js"
|
src: "web-ui/nostr-provider.js"
|
||||||
dest: "/usr/share/nginx/html/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: ["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"]
|
- exec: ["nginx", "-s", "reload"]
|
||||||
|
|
||||||
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
app:
|
app:
|
||||||
id: mempool
|
id: mempool
|
||||||
name: Mempool Explorer
|
name: Mempool Explorer
|
||||||
version: 3.0.0
|
version: 3.3.1-archy1
|
||||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
# 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:
|
# 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.
|
# 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.
|
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||||
|
|
||||||
container:
|
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://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ app:
|
|||||||
container:
|
container:
|
||||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
# Portainer fetches Git sources and images from services on this same node.
|
||||||
|
# Rootless pasta copies the host LAN address into its namespace, so a LAN
|
||||||
|
# URL points back at Portainer itself. Give it a private address with the
|
||||||
|
# supported rootless slirp backend; public app URLs still traverse the gate.
|
||||||
|
network: slirp4netns
|
||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
|
# Snapshot state before an upgrade recreates this app with new networking.
|
||||||
|
backup_before_runtime_change: true
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -67,13 +67,13 @@
|
|||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"title": "Mempool Explorer",
|
"title": "Mempool Explorer",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1-archy1",
|
||||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
"icon": "/assets/img/app-icons/mempool.webp",
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"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",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.10-alpha"
|
version = "1.8.21-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.10-alpha"
|
version = "1.8.21-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
|
|||||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||||
|
|
||||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
# Nostr (node discovery + NIP-44 encrypted peer handshake).
|
||||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
# 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
|
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
|
|||||||
@@ -162,11 +162,33 @@ impl ApiHandler {
|
|||||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(
|
||||||
|
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
|
||||||
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header("Content-Range", format!("bytes */{total}"))
|
||||||
|
.body(hyper::Body::empty())
|
||||||
|
.unwrap()),
|
||||||
|
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
hyper::Body::from("Content not found"),
|
hyper::Body::from("Content not found"),
|
||||||
)),
|
)),
|
||||||
|
// Not a 404: a paid request may already have been charged by the
|
||||||
|
// time this fails, and "not found" hid the real error entirely.
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Serving content {content_id} failed: {e:#}");
|
||||||
|
Ok(build_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("Failed to serve content"),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
|||||||
cors_origin: &str,
|
cors_origin: &str,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
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}");
|
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||||
// A bare reqwest::get() uses the default client, which rejects the
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||||
/// the seller already claimed the token (then the value is genuinely gone).
|
/// 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) {
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||||
let res = match backend {
|
let res = match backend {
|
||||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
.await
|
.await
|
||||||
@@ -32,16 +32,81 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
|||||||
_ => ecash::receive_token(data_dir, token).await,
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
};
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(sats) => tracing::info!(
|
Ok(sats) => {
|
||||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||||
),
|
format!("Refunded {sats} sats to your wallet.")
|
||||||
Err(e) => tracing::warn!(
|
}
|
||||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
Err(e) => {
|
||||||
claimed it): {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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only pass through the peer's bounded, printable explanation; refund status
|
||||||
|
/// is always determined locally and must never come from the peer's wording.
|
||||||
|
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||||
|
let reason = serde_json::from_str::<serde_json::Value>(body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
|
||||||
|
match reason {
|
||||||
|
Some(reason) if !reason.trim().is_empty() => {
|
||||||
|
let clean: String = reason
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(240)
|
||||||
|
.collect();
|
||||||
|
format!("Seller response ({status}): {clean}")
|
||||||
|
}
|
||||||
|
_ => format!("Peer returned an error ({status})."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl RpcHandler {
|
||||||
/// List content I'm sharing.
|
/// List content I'm sharing.
|
||||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||||
@@ -463,17 +528,10 @@ impl RpcHandler {
|
|||||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
use base64::Engine;
|
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||||
return Ok(serde_json::json!({
|
result["already_owned"] = serde_json::json!(true);
|
||||||
"owned": true,
|
result["filename"] = serde_json::json!(o.filename);
|
||||||
"already_owned": true,
|
return Ok(result);
|
||||||
"filename": o.filename,
|
|
||||||
"mime_type": mime,
|
|
||||||
"size_bytes": bytes.len(),
|
|
||||||
"paid_sats": 0,
|
|
||||||
"data_base64":
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
// Cache record exists but bytes are gone — fall through and
|
// Cache record exists but bytes are gone — fall through and
|
||||||
// repurchase rather than stranding the user.
|
// repurchase rather than stranding the user.
|
||||||
@@ -545,31 +603,30 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let path = format!("/content/{}", content_id);
|
let path = format!("/content/{}", content_id);
|
||||||
// Surface a real reason instead of the generic sanitized error (#30):
|
// 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
|
// A bearer token must not be replayed after an ambiguous delivery.
|
||||||
// here means the peer is genuinely unreachable on both transports.
|
// A transport error can mean the seller received it without replying.
|
||||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
let (response, transport) =
|
||||||
fips_npub.as_deref(),
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
onion,
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
&path,
|
.header("X-Federation-DID", local_did)
|
||||||
)
|
.header("X-Payment-Token", token_str.clone())
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.single_delivery()
|
||||||
.header("X-Federation-DID", local_did)
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
.header("X-Payment-Token", token_str.clone())
|
.send_get()
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.await
|
||||||
.send_get()
|
{
|
||||||
.await
|
Ok(v) => v,
|
||||||
{
|
Err(e) => {
|
||||||
Ok(v) => v,
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
Err(e) => {
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
// The token was already minted/spent — reclaim it so the buyer
|
let refund =
|
||||||
// doesn't lose the value when the seller was simply unreachable.
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
return Ok(serde_json::json!({
|
||||||
return Ok(serde_json::json!({
|
"error": format!("The purchase could not be completed. {refund}")
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
}));
|
||||||
}));
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
// Record which transport actually reached the peer (B14).
|
// Record which transport actually reached the peer (B14).
|
||||||
if let Err(e) = crate::federation::record_peer_transport(
|
if let Err(e) = crate::federation::record_peer_transport(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
@@ -583,25 +640,17 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
// Payment was rejected by the seller. Surface the most likely cause
|
// A 402 can mean mint validation, network failure, underpayment,
|
||||||
// per backend — for ecash both sides must share a redemption network
|
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||||
// (a Cashu mint, or a Fedimint federation).
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
"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
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
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;
|
||||||
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",
|
|
||||||
};
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": format!("The seller could not verify the payment. {refund}")
|
||||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
|
||||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,9 +658,9 @@ impl RpcHandler {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
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!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,10 +674,17 @@ impl RpcHandler {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
let bytes = response
|
let bytes = match response.bytes().await {
|
||||||
.bytes()
|
Ok(bytes) => bytes,
|
||||||
.await
|
Err(error) => {
|
||||||
.context("Failed to read response body")?;
|
tracing::warn!("paid download: response body failed: {error}");
|
||||||
|
let refund =
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||||
@@ -658,63 +714,21 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
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):
|
// The durable purchased-content cache above is primary. A Files copy
|
||||||
// Photos for images/video, Music for audio, Documents otherwise —
|
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
let filed =
|
||||||
// from the purchase cache; this makes the file ALSO show up where
|
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||||
// files live, on every device, without relying on a browser
|
match filed {
|
||||||
// download. Best-effort: never fail a paid download over it.
|
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||||
{
|
Err(error) => tracing::warn!(
|
||||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||||
"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()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||||
Ok(serde_json::json!({
|
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||||
"data": encoded,
|
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||||
"size": bytes.len(),
|
Ok(result)
|
||||||
"paid_sats": price_sats,
|
|
||||||
"ecash_backend": used_backend,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"owned": true,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||||
@@ -1387,3 +1401,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "content_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||||
|
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
||||||
|
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
|
||||||
|
assert!(message.starts_with("Seller response (503"));
|
||||||
|
assert!(message.ends_with("Cannot read file"));
|
||||||
|
assert!(!message.contains('\n') && !message.contains('\0'));
|
||||||
|
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
|
||||||
|
assert!(seller_error_message(status, &body).chars().count() < 300);
|
||||||
|
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
|
||||||
|
assert_eq!(
|
||||||
|
seller_error_message(status, body),
|
||||||
|
"Peer returned an error (503 Service Unavailable)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,6 +269,8 @@ impl RpcHandler {
|
|||||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).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-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-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
|
||||||
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
||||||
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ impl RpcHandler {
|
|||||||
"did": id.did,
|
"did": id.did,
|
||||||
"created_at": id.created_at,
|
"created_at": id.created_at,
|
||||||
"is_default": is_default,
|
"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_pubkey": nostr_pubkey,
|
||||||
"nostr_npub": nostr_npub,
|
"nostr_npub": nostr_npub,
|
||||||
"profile": id.profile,
|
"profile": id.profile,
|
||||||
|
|||||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
|||||||
pending_open_local_balance: Option<LndAmount>,
|
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 {
|
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> {
|
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_bytes = read_lnd_admin_macaroon().await?;
|
||||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
let get_info: LndGetInfoResponse = client
|
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
&client,
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||||
.send()
|
&macaroon_hex,
|
||||||
.await
|
)
|
||||||
.context("LND REST connection failed")?
|
.await?;
|
||||||
.json()
|
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||||
.await
|
&client,
|
||||||
.context("Failed to parse LND getinfo response")?;
|
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||||
|
&macaroon_hex,
|
||||||
let channel_balance: LndChannelBalanceResponse = match client
|
)
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
.await?;
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||||
.send()
|
&client,
|
||||||
.await
|
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||||
{
|
&macaroon_hex,
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
)
|
||||||
local_balance: None,
|
.await?;
|
||||||
pending_open_local_balance: None,
|
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||||
}),
|
checked_balances(wallet_balance, channel_balance)?;
|
||||||
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 (identity_pubkey, uris) = map_identity(&get_info);
|
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||||
|
|
||||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
|||||||
num_peers: get_info.num_peers.unwrap_or(0),
|
num_peers: get_info.num_peers.unwrap_or(0),
|
||||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||||
block_height: get_info.block_height.unwrap_or(0),
|
block_height: get_info.block_height.unwrap_or(0),
|
||||||
balance_sats: wallet_balance
|
balance_sats,
|
||||||
.total_balance
|
channel_balance_sats,
|
||||||
.and_then(|s| s.parse().ok())
|
pending_open_balance,
|
||||||
.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),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(serde_json::to_value(info)?)
|
Ok(serde_json::to_value(info)?)
|
||||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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.
|
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||||
|
|
||||||
@@ -341,3 +462,44 @@ mod tests {
|
|||||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
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
|
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||||
/// operator.
|
/// 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() {
|
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut bad_minutes: u32 = 0;
|
let mut bad_minutes: u32 = 0;
|
||||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||||
|
let mut last_height: Option<u64> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
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 {
|
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||||
continue;
|
continue;
|
||||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
bad_minutes = 0; // down/locked — not the wedge signature
|
bad_minutes = 0; // down/locked — not the wedge signature
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
bad_minutes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
.get("num_pending_channels")
|
.get("num_pending_channels")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0);
|
.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 {
|
if !wedged {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
|||||||
Ok((client, macaroon_hex))
|
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",
|
"must be",
|
||||||
"cannot",
|
"cannot",
|
||||||
"Password",
|
"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
|
// OTA apply/download errors are all operator-actionable ("download it
|
||||||
// again", "download first") — sanitizing them to "Operation failed"
|
// again", "download first") — sanitizing them to "Operation failed"
|
||||||
// left users stuck with no idea what to do, and hid the "already
|
// 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);
|
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]
|
#[test]
|
||||||
fn tor_unavailable_precondition_passes_through() {
|
fn tor_unavailable_precondition_passes_through() {
|
||||||
let msg = "Tor address not available. Tor may not be running.";
|
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.
|
/// Deterministic: same session token always produces the same CSRF token.
|
||||||
/// Survives backend restarts because it depends only on the session token
|
/// Survives backend restarts because it depends only on the session token
|
||||||
/// and the on-disk remember secret (not ephemeral state).
|
/// 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 hmac::{Hmac, Mac};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod nostr;
|
|||||||
mod onboarding_gate;
|
mod onboarding_gate;
|
||||||
mod openwrt;
|
mod openwrt;
|
||||||
mod package;
|
mod package;
|
||||||
|
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||||
pub(crate) use package::wyoming_satellite_keeper;
|
pub(crate) use package::wyoming_satellite_keeper;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod pine_status;
|
mod pine_status;
|
||||||
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
|
|||||||
// never added to it — the Phase-10 hard constraint this crate must hold.
|
// 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
|
// The list's *contents* are unchanged; only its read-visibility widens from
|
||||||
// "this module" to "this crate".
|
// "this module" to "this crate".
|
||||||
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
|
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
|
||||||
use middleware::{
|
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
|
||||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
|
|
||||||
};
|
|
||||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
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).
|
/// Default dev password when no user is set up (matches mock-backend).
|
||||||
/// Dev builds only — the pre-setup login bypass that reads this is
|
/// Dev builds only — the pre-setup login bypass that reads this is
|
||||||
/// cfg-gated out of release binaries.
|
/// cfg-gated out of release binaries.
|
||||||
@@ -291,6 +333,18 @@ impl RpcHandler {
|
|||||||
|
|
||||||
debug!("RPC method: {}", rpc_req.method);
|
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
|
// Enforce authentication for non-allowlisted methods
|
||||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||||
let mut new_session_cookies: Option<(String, String)> = None;
|
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.
|
// 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 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).
|
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||||
let csrf_exempt = matches!(
|
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
|
||||||
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"
|
|
||||||
);
|
|
||||||
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
||||||
let csrf_header = parts
|
let csrf_header = parts
|
||||||
.headers
|
.headers
|
||||||
@@ -735,3 +775,62 @@ impl RpcHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod nostr_signing_origin_tests {
|
||||||
|
use super::*;
|
||||||
|
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
|
||||||
|
|
||||||
|
fn headers(origin: Option<&str>) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
|
||||||
|
assert!(nostr_signing_origin_allowed(&headers(None), false));
|
||||||
|
assert!(nostr_signing_origin_allowed(
|
||||||
|
&headers(Some("https://node.local")),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(nostr_signing_origin_allowed(
|
||||||
|
&headers(Some("http://192.0.2.10")),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
|
||||||
|
assert!(!nostr_signing_origin_allowed(
|
||||||
|
&headers(Some("https://node.local:8337")),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(!nostr_signing_origin_allowed(
|
||||||
|
&headers(Some("https://node.local:7778")),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(nostr_signing_origin_allowed(
|
||||||
|
&headers(Some("http://localhost:5173")),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_probe_contract_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
|
||||||
|
const PROBE: &str = "system.get-hostname";
|
||||||
|
const DISPATCHER: &str = include_str!("dispatcher.rs");
|
||||||
|
|
||||||
|
assert!(csrf_exempt_method(PROBE));
|
||||||
|
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
|
||||||
|
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
|
||||||
|
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ impl RpcHandler {
|
|||||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||||
// *package* name, never an on-disk filename.
|
// *package* name, never an on-disk filename.
|
||||||
let tollgate_installed = router
|
let tollgate_installed = router
|
||||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
.run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||||
.map(|(_, code)| code == 0)
|
.map(|(_, code)| code == 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -150,6 +150,7 @@ impl RpcHandler {
|
|||||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
"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(),
|
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||||
"mint_url": router.uci_get("tollgate.main.mint_url").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 {
|
} else {
|
||||||
serde_json::json!({ "installed": false })
|
serde_json::json!({ "installed": false })
|
||||||
@@ -199,10 +200,15 @@ impl RpcHandler {
|
|||||||
///
|
///
|
||||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||||
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
|
/// "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_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.
|
/// 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(
|
pub(super) async fn handle_openwrt_provision_tollgate(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -240,12 +246,35 @@ impl RpcHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
|
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
|
let mint_url = p
|
||||||
.get("mint_url")
|
.get("mint_url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&default_mint_url)
|
.unwrap_or(&default_mint_url)
|
||||||
|
.trim_end_matches('/')
|
||||||
.to_string();
|
.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 {
|
let config = TollGateConfig {
|
||||||
ssid: "archipelago".to_string(),
|
ssid: "archipelago".to_string(),
|
||||||
mint_url,
|
mint_url,
|
||||||
@@ -256,6 +285,7 @@ impl RpcHandler {
|
|||||||
.unwrap_or(60_000),
|
.unwrap_or(60_000),
|
||||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
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),
|
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 —
|
// Blocking SSH session, and provision runs `opkg install` over it —
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
|||||||
.to_string();
|
.to_string();
|
||||||
super::validation::validate_app_id(&package_id)?;
|
super::validation::validate_app_id(&package_id)?;
|
||||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||||
|
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||||
|
|
||||||
// Reject if already in a transitional lifecycle (prevents double-click
|
// Reject if already in a transitional lifecycle (prevents double-click
|
||||||
// queuing two installs on the same package).
|
// queuing two installs on the same package).
|
||||||
@@ -88,6 +89,15 @@ impl RpcHandler {
|
|||||||
match handler.handle_package_install(params).await {
|
match handler.handle_package_install(params).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("package.install {}: complete", package_id_spawn);
|
info!("package.install {}: complete", package_id_spawn);
|
||||||
|
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||||
|
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
crate::crash_recovery::mark_installed(
|
||||||
|
&handler.config.data_dir,
|
||||||
|
&package_id_spawn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
// The install pipeline has verified the container is up
|
// The install pipeline has verified the container is up
|
||||||
// and healthy (see install.rs post-start exit check).
|
// and healthy (see install.rs post-start exit check).
|
||||||
// Kick the scanner first so the fresh manifest (with
|
// Kick the scanner first so the fresh manifest (with
|
||||||
@@ -183,17 +193,20 @@ impl RpcHandler {
|
|||||||
// phase is cleared (None) so no stale InstallPhase
|
// phase is cleared (None) so no stale InstallPhase
|
||||||
// lingers on the card.
|
// lingers on the card.
|
||||||
let err_msg = format!("Install failed: {:#}", e);
|
let err_msg = format!("Install failed: {:#}", e);
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
.state_manager
|
||||||
entry.state = PackageState::Stopped;
|
.mutate_data(|data| {
|
||||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||||
size: 0,
|
entry.state = PackageState::Stopped;
|
||||||
downloaded: 0,
|
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||||
phase: None,
|
size: 0,
|
||||||
message: Some(err_msg),
|
downloaded: 0,
|
||||||
});
|
phase: None,
|
||||||
handler.state_manager.update_data(data).await;
|
message: Some(err_msg),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -251,6 +264,11 @@ impl RpcHandler {
|
|||||||
match handler.handle_package_uninstall(params).await {
|
match handler.handle_package_uninstall(params).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("package.uninstall {}: complete", package_id_spawn);
|
info!("package.uninstall {}: complete", package_id_spawn);
|
||||||
|
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||||
|
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
|
||||||
|
.await;
|
||||||
|
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
|
||||||
|
}
|
||||||
// Inner handler already removed the package entry on
|
// Inner handler already removed the package entry on
|
||||||
// success. Nothing more to do here.
|
// success. Nothing more to do here.
|
||||||
}
|
}
|
||||||
@@ -294,6 +312,12 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
super::validation::validate_app_id(&package_id)?;
|
super::validation::validate_app_id(&package_id)?;
|
||||||
|
// Update is stop → pull → remove → recreate, i.e. a fresh start by
|
||||||
|
// another name: on a disk that shrank since install it would resume
|
||||||
|
// cuprate's unprunable sync unchecked. Same gate as install and
|
||||||
|
// start, run BEFORE the Updating flip so a refusal leaves the app
|
||||||
|
// cleanly in its previous state.
|
||||||
|
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||||
|
|
||||||
// Reject if already in a transitional lifecycle.
|
// Reject if already in a transitional lifecycle.
|
||||||
{
|
{
|
||||||
@@ -323,7 +347,7 @@ impl RpcHandler {
|
|||||||
let package_id_spawn = package_id.clone();
|
let package_id_spawn = package_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match handler.handle_package_update(params).await {
|
match handler.handle_package_update(params).await {
|
||||||
Ok(_) => {
|
Ok(result) => {
|
||||||
info!("package.update {}: complete", package_id_spawn);
|
info!("package.update {}: complete", package_id_spawn);
|
||||||
// Same reasoning as install: the merge_preserving_transitional
|
// Same reasoning as install: the merge_preserving_transitional
|
||||||
// helper treats Updating as RPC-owned, so we MUST write the
|
// helper treats Updating as RPC-owned, so we MUST write the
|
||||||
@@ -338,7 +362,11 @@ impl RpcHandler {
|
|||||||
set_package_state(
|
set_package_state(
|
||||||
&handler.state_manager,
|
&handler.state_manager,
|
||||||
&package_id_spawn,
|
&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;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -371,52 +399,56 @@ impl RpcHandler {
|
|||||||
/// call, but fires before the spawn so the UI sees it immediately.
|
/// call, but fires before the spawn so the UI sees it immediately.
|
||||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| PackageDataEntry {
|
.entry(package_id.to_string())
|
||||||
state: PackageState::Installing,
|
.or_insert_with(|| PackageDataEntry {
|
||||||
health: None,
|
ui_ready: None,
|
||||||
exit_code: None,
|
state: PackageState::Installing,
|
||||||
static_files: StaticFiles {
|
health: None,
|
||||||
license: String::new(),
|
exit_code: None,
|
||||||
instructions: String::new(),
|
static_files: StaticFiles {
|
||||||
// Leave icon empty during the transient Installing window:
|
license: String::new(),
|
||||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
instructions: String::new(),
|
||||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
// Leave icon empty during the transient Installing window:
|
||||||
// the scanner refreshes the entry. The frontend's `icon`
|
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||||
// has the correct extensions for known apps.
|
// the scanner refreshes the entry. The frontend's `icon`
|
||||||
icon: String::new(),
|
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||||
},
|
// has the correct extensions for known apps.
|
||||||
manifest: Manifest {
|
icon: String::new(),
|
||||||
id: package_id.to_string(),
|
},
|
||||||
title: package_id.to_string(),
|
manifest: Manifest {
|
||||||
version: String::new(),
|
id: package_id.to_string(),
|
||||||
description: Description {
|
title: package_id.to_string(),
|
||||||
short: "Installing...".to_string(),
|
version: String::new(),
|
||||||
long: String::new(),
|
description: Description {
|
||||||
},
|
short: "Installing...".to_string(),
|
||||||
release_notes: String::new(),
|
long: String::new(),
|
||||||
license: String::new(),
|
},
|
||||||
wrapper_repo: String::new(),
|
release_notes: String::new(),
|
||||||
upstream_repo: String::new(),
|
license: String::new(),
|
||||||
support_site: String::new(),
|
wrapper_repo: String::new(),
|
||||||
marketing_site: String::new(),
|
upstream_repo: String::new(),
|
||||||
donation_url: None,
|
support_site: String::new(),
|
||||||
author: None,
|
marketing_site: String::new(),
|
||||||
website: None,
|
donation_url: None,
|
||||||
interfaces: None,
|
author: None,
|
||||||
tier: None,
|
website: None,
|
||||||
},
|
interfaces: None,
|
||||||
installed: None,
|
tier: None,
|
||||||
install_progress: None,
|
},
|
||||||
uninstall_stage: None,
|
installed: None,
|
||||||
available_update: None,
|
install_progress: None,
|
||||||
});
|
uninstall_stage: None,
|
||||||
entry.state = PackageState::Installing;
|
available_update: None,
|
||||||
state_manager.update_data(data).await;
|
});
|
||||||
|
entry.ui_ready = Some(false);
|
||||||
|
entry.state = PackageState::Installing;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the failed install still has a real footprint: any container
|
/// True when the failed install still has a real footprint: any container
|
||||||
@@ -474,20 +506,23 @@ async fn remove_entry_with_notification(
|
|||||||
id_prefix: &str,
|
id_prefix: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
data.package_data.remove(package_id);
|
.state_manager
|
||||||
data.notifications.push(crate::data_model::Notification {
|
.mutate_data(|data| {
|
||||||
id: format!("{id_prefix}-{package_id}"),
|
data.package_data.remove(package_id);
|
||||||
level: crate::data_model::NotificationLevel::Error,
|
data.notifications.push(crate::data_model::Notification {
|
||||||
title: format!("Could not install {package_id}"),
|
id: format!("{id_prefix}-{package_id}"),
|
||||||
message: message.to_string(),
|
level: crate::data_model::NotificationLevel::Error,
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
title: format!("Could not install {package_id}"),
|
||||||
app_id: Some(package_id.to_string()),
|
message: message.to_string(),
|
||||||
});
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
while data.notifications.len() > 20 {
|
app_id: Some(package_id.to_string()),
|
||||||
data.notifications.remove(0);
|
});
|
||||||
}
|
while data.notifications.len() > 20 {
|
||||||
handler.state_manager.update_data(data).await;
|
data.notifications.remove(0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||||
@@ -497,18 +532,23 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
} else {
|
entry.ui_ready = Some(false);
|
||||||
warn!(
|
}
|
||||||
"flip_package_state: no entry for {} — cannot flip",
|
entry.state = new_state;
|
||||||
package_id
|
} else {
|
||||||
);
|
warn!(
|
||||||
}
|
"flip_package_state: no entry for {} — cannot flip",
|
||||||
prev
|
package_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set state unconditionally (no-op if entry no longer exists).
|
/// Set state unconditionally (no-op if entry no longer exists).
|
||||||
@@ -517,13 +557,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||||
@@ -534,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.uninstall_stage = None;
|
if new_state != PackageState::Running {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
entry.uninstall_stage = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kick the container scanner to run immediately and wait for it to finish
|
/// Kick the container scanner to run immediately and wait for it to finish
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
|
|||||||
/// hardcoded id list below — a new app just declares the dependency instead
|
/// hardcoded id list below — a new app just declares the dependency instead
|
||||||
/// of needing a code change here.
|
/// of needing a code change here.
|
||||||
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
|
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
|
||||||
|
// Registry-only apps need the same guard as OTA-bundled manifests. Honor
|
||||||
|
// the verified catalog's effective manifest before the disk fallback.
|
||||||
|
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _)| id == package_id)
|
||||||
|
{
|
||||||
|
if let Some(manifest) =
|
||||||
|
crate::container::app_catalog::catalog_manifest_overlay(package_id, value)
|
||||||
|
{
|
||||||
|
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
|
||||||
|
}
|
||||||
|
}
|
||||||
for apps_dir in manifest_apps_dirs() {
|
for apps_dir in manifest_apps_dirs() {
|
||||||
let path = apps_dir.join(package_id).join("manifest.yml");
|
let path = apps_dir.join(package_id).join("manifest.yml");
|
||||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||||
@@ -670,6 +682,50 @@ async fn detect_disk_gb() -> u64 {
|
|||||||
.unwrap_or(u64::MAX)
|
.unwrap_or(u64::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
|
||||||
|
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
|
||||||
|
/// shared with the boot reconciler so install/start and boot can never
|
||||||
|
/// disagree about where cuprate may run.
|
||||||
|
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||||
|
|
||||||
|
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
|
||||||
|
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
|
||||||
|
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
|
||||||
|
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
|
||||||
|
/// to run cuprate at all rather than let it sync until the filesystem fills —
|
||||||
|
/// which took Archipelago itself down on nodes with too little disk.
|
||||||
|
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
|
||||||
|
A Monero node cannot run pruned — upstream cuprate has no pruning \
|
||||||
|
support — so the chain (~250 GB and growing) would fill the disk and \
|
||||||
|
take Archipelago down with it. Attach a larger disk (or move \
|
||||||
|
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
|
||||||
|
pruned on smaller disks; Monero currently cannot.",
|
||||||
|
CUPRATE_MIN_DISK_GB, disk_gb
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure decision half of the cuprate disk gate — testable without df.
|
||||||
|
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
|
||||||
|
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
|
||||||
|
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
|
||||||
|
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
|
||||||
|
/// when df fails, so an unreadable disk never blocks an install).
|
||||||
|
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
|
||||||
|
if package_id != "cuprate" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let disk_gb = detect_disk_gb().await;
|
||||||
|
if let Some(message) = cuprate_disk_gate(disk_gb) {
|
||||||
|
anyhow::bail!(message);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Log informational messages about optional dependencies.
|
/// Log informational messages about optional dependencies.
|
||||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||||
@@ -873,9 +929,9 @@ pub(super) fn configure_fedimint_lnd(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
|
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
|
||||||
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
||||||
startup_order, BITCOIN_WARMUP_BUDGET,
|
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
|
||||||
};
|
};
|
||||||
use archipelago_container::Dependency;
|
use archipelago_container::Dependency;
|
||||||
|
|
||||||
@@ -1011,12 +1067,51 @@ mod tests {
|
|||||||
// edit to `requires_unpruned_bitcoin`.
|
// edit to `requires_unpruned_bitcoin`.
|
||||||
assert!(manifest_declares_archival_bitcoin("electrumx"));
|
assert!(manifest_declares_archival_bitcoin("electrumx"));
|
||||||
assert!(manifest_declares_archival_bitcoin("mempool"));
|
assert!(manifest_declares_archival_bitcoin("mempool"));
|
||||||
|
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../../apps/angor-indexer/manifest.yml"
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(dependency_list_declares_archival_bitcoin(
|
||||||
|
&angor.app.dependencies
|
||||||
|
));
|
||||||
// An app whose manifest exists but never declares the marker.
|
// An app whose manifest exists but never declares the marker.
|
||||||
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
|
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
|
||||||
// An id with no manifest on disk at all.
|
// An id with no manifest on disk at all.
|
||||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
|
||||||
|
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
|
||||||
|
assert!(cuprate_disk_gate(0).is_some());
|
||||||
|
assert!(cuprate_disk_gate(250).is_some());
|
||||||
|
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
|
||||||
|
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
|
||||||
|
assert!(cuprate_disk_gate(1000).is_none());
|
||||||
|
// df failure reads as u64::MAX — an unreadable disk must not block.
|
||||||
|
assert!(cuprate_disk_gate(u64::MAX).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
|
||||||
|
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
|
||||||
|
assert!(msg.contains("cannot run pruned"), "{msg}");
|
||||||
|
assert!(msg.contains("larger disk"), "{msg}");
|
||||||
|
assert!(msg.contains("250 GB"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cuprate_disk_gate_only_applies_to_cuprate() {
|
||||||
|
// Every other package passes regardless of disk — including the
|
||||||
|
// bitcoin apps, which self-prune via their manifest entrypoint.
|
||||||
|
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||||
|
super::check_cuprate_disk_compatibility(package_id)
|
||||||
|
.await
|
||||||
|
.expect("non-cuprate installs must not be gated here");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod dep_wait {
|
mod dep_wait {
|
||||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ use super::config::{
|
|||||||
is_readonly_compatible, is_valid_docker_image,
|
is_readonly_compatible, is_valid_docker_image,
|
||||||
};
|
};
|
||||||
use super::dependencies::{
|
use super::dependencies::{
|
||||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||||
DEP_WAIT_MAX_ATTEMPTS,
|
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||||
};
|
};
|
||||||
use super::progress::parse_pull_progress;
|
use super::progress::parse_pull_progress;
|
||||||
use super::validation::validate_app_id;
|
use super::validation::validate_app_id;
|
||||||
@@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn patch_indeedhub_nostr_provider() {
|
fn patched_indeedhub_nginx_config(original: &str) -> String {
|
||||||
|
let mut conf = original
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.contains("X-Frame-Options"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
conf.push('\n');
|
||||||
|
if !conf.contains("location = /nostr-provider.js {") {
|
||||||
|
conf = conf.replace(
|
||||||
|
"location = /sw.js {",
|
||||||
|
"location = /nostr-provider.js {\n\
|
||||||
|
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||||
|
expires off;\n\
|
||||||
|
}\n\n\
|
||||||
|
location = /sw.js {",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||||
|
conf = conf.replacen(
|
||||||
|
"try_files $uri $uri/ /index.html;",
|
||||||
|
"try_files $uri $uri/ /index.html;\n\
|
||||||
|
sub_filter_once on;\n\
|
||||||
|
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
conf = conf.replace(
|
||||||
|
"src=\"/nostr-provider.js\"",
|
||||||
|
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
|
||||||
|
);
|
||||||
|
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
|
||||||
|
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
|
||||||
|
conf.replace(
|
||||||
|
"proxy_set_header X-Forwarded-Prefix /api;",
|
||||||
|
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn patch_indeedhub_nostr_provider() {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
// Frontend assets can change during a dashboard-only OTA while the
|
||||||
.args([
|
// IndeedHub container keeps running. Reconcile the injected provider on
|
||||||
"exec",
|
// daemon startup as well as app install/start, but stay quiet when the app
|
||||||
"indeedhub",
|
// is not installed or is intentionally stopped.
|
||||||
"sed",
|
let running = tokio::process::Command::new("podman")
|
||||||
"-i",
|
.args(["inspect", "-f", "{{.State.Running}}", "indeedhub"])
|
||||||
"/X-Frame-Options/d",
|
|
||||||
"/etc/nginx/conf.d/default.conf",
|
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await
|
||||||
|
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
|
||||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
.unwrap_or(false);
|
||||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
if !running {
|
||||||
let _ = tokio::process::Command::new("podman")
|
return;
|
||||||
.args([
|
|
||||||
"cp",
|
|
||||||
provider_src,
|
|
||||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let check = tokio::process::Command::new("podman")
|
// `podman exec` cannot always join a rootless container's delegated cgroup
|
||||||
.args([
|
// from the system service, while Podman 5's copier refuses to overwrite an
|
||||||
"exec",
|
// existing regular file. Mount the rootless storage namespace instead;
|
||||||
"indeedhub",
|
// this replaces both files without entering the container's cgroup.
|
||||||
"grep",
|
let unique = std::time::SystemTime::now()
|
||||||
"-q",
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
"nostr-provider",
|
.map(|duration| duration.as_nanos())
|
||||||
"/etc/nginx/conf.d/default.conf",
|
.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()
|
.output()
|
||||||
.await;
|
.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 provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||||
let cat_out = tokio::process::Command::new("podman")
|
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
|
||||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
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()
|
.output()
|
||||||
.await;
|
.await
|
||||||
|
.map(|out| out.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
if let Ok(out) = cat_out {
|
let copy_out = tokio::process::Command::new("podman")
|
||||||
if out.status.success() {
|
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
|
||||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
.output()
|
||||||
let conf = conf.replace(
|
.await;
|
||||||
"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 tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
let mut config_copied = false;
|
||||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
if let Ok(out) = copy_out {
|
||||||
let _ = tokio::process::Command::new("podman")
|
if out.status.success() {
|
||||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
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()
|
.output()
|
||||||
.await;
|
.await
|
||||||
let _ = tokio::fs::remove_file(tmp_path).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")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args([
|
.args(["unshare", "podman", "unmount", "indeedhub"])
|
||||||
"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()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let reload = tokio::process::Command::new("podman")
|
let reload = tokio::process::Command::new("podman")
|
||||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
.args(["kill", "--signal", "HUP", "indeedhub"])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
match reload {
|
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");
|
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
|
||||||
}
|
}
|
||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"IndeeHub nginx reload failed: {}",
|
"IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}",
|
||||||
|
provider_copied,
|
||||||
|
config_copied,
|
||||||
String::from_utf8_lossy(&o.stderr)
|
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
|
// an older version pins it so install_fresh resolves that image and the
|
||||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
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()) {
|
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||||
persist_install_version_selection(package_id, version).await;
|
persist_install_version_selection(package_id, version).await;
|
||||||
}
|
}
|
||||||
@@ -306,6 +378,7 @@ impl RpcHandler {
|
|||||||
// failing instantly.
|
// failing instantly.
|
||||||
let deps = self.gate_install_deps(package_id).await?;
|
let deps = self.gate_install_deps(package_id).await?;
|
||||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||||
|
check_cuprate_disk_compatibility(package_id).await?;
|
||||||
log_optional_dep_info(package_id, &deps);
|
log_optional_dep_info(package_id, &deps);
|
||||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||||
// Materialise the RPC password file before any install path
|
// Materialise the RPC password file before any install path
|
||||||
@@ -472,7 +545,7 @@ impl RpcHandler {
|
|||||||
// Keep legacy install flow as default while migration is in progress.
|
// Keep legacy install flow as default while migration is in progress.
|
||||||
if orchestrator_managed {
|
if orchestrator_managed {
|
||||||
let orchestrator_app_id = orchestrator_install_app_id(package_id);
|
let orchestrator_app_id = orchestrator_install_app_id(package_id);
|
||||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
self.set_install_phase(package_id, InstallPhase::PreparingApp)
|
||||||
.await;
|
.await;
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
||||||
@@ -500,6 +573,9 @@ impl RpcHandler {
|
|||||||
"message": format!("Package {} installed and started", package_id)
|
"message": format!("Package {} installed and started", package_id)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
Err(e) if e.downcast_ref::<crate::container::prod_orchestrator::InstallPrerequisiteError>().is_some() => {
|
||||||
|
return Err(super::dependencies::DependencyGateError(e.to_string()).into());
|
||||||
|
}
|
||||||
Err(e) if is_unknown_app_id_error(&e) => {
|
Err(e) if is_unknown_app_id_error(&e) => {
|
||||||
info!(
|
info!(
|
||||||
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
|
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
|
||||||
@@ -1620,152 +1696,16 @@ 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" {
|
if package_id == "indeedhub" {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
patch_indeedhub_nostr_provider().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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
// Gitea owns its public URL and security settings in app.ini, including
|
||||||
// in a new tab on that direct port so absolute asset URLs must be
|
// values chosen in its first-run setup. Do not rewrite operator values
|
||||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
// or claim success from best-effort grep/sed commands. The app gate
|
||||||
if package_id == "gitea" {
|
// fronts its declared HTTP port and handles frame headers separately.
|
||||||
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
|
|
||||||
|
|
||||||
// Set ROOT_URL to the direct launch route so links/assets stay
|
|
||||||
// anchored under the same origin Gitea is launched from.
|
|
||||||
let host_ip = &self.config.host_ip;
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["exec", "gitea", "sh", "-c",
|
|
||||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["exec", "gitea", "sh", "-c",
|
|
||||||
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
|
|
||||||
host_ip
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if package_id == "nextcloud" {
|
if package_id == "nextcloud" {
|
||||||
let host_ip = &self.config.host_ip;
|
let host_ip = &self.config.host_ip;
|
||||||
@@ -2094,25 +2034,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||||
match package_id {
|
// Never kill by port: another app or the management gate may own it.
|
||||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
|
||||||
"searxng" => cleanup_stale_pasta_port("8888").await,
|
|
||||||
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
|
|
||||||
"gitea" => {
|
|
||||||
cleanup_stale_pasta_port("3001").await;
|
|
||||||
cleanup_stale_pasta_port("2222").await;
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
}
|
|
||||||
"nginx-proxy-manager" => {
|
|
||||||
cleanup_stale_pasta_port("8081").await;
|
|
||||||
cleanup_stale_pasta_port("8084").await;
|
|
||||||
cleanup_stale_pasta_port("8444").await;
|
|
||||||
}
|
|
||||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
|
||||||
"portainer" => cleanup_stale_pasta_port("9000").await,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_command_tail(
|
fn install_command_tail(
|
||||||
@@ -2237,93 +2160,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
match package_id {
|
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
|
||||||
"grafana"
|
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
return true;
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"homeassistant" | "home-assistant"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8123").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"searxng"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8888").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"uptime-kuma"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("3002").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
|
|
||||||
cleanup_stale_pasta_port("3001").await;
|
|
||||||
cleanup_stale_pasta_port("2222").await;
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"nginx-proxy-manager"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8081").await;
|
|
||||||
cleanup_stale_pasta_port("8084").await;
|
|
||||||
cleanup_stale_pasta_port("8444").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"nextcloud"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8085").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"portainer"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("9000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
}
|
false
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
|
||||||
// NEVER kill our own process. The daemon holds catalog app ports over
|
|
||||||
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
|
||||||
// terminate archipelago itself mid-install — installs failed and apps
|
|
||||||
// vanished on a test node 2026-07-27. Kill every listener on the port
|
|
||||||
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
|
||||||
let self_pid = std::process::id();
|
|
||||||
let kill_listener = format!(
|
|
||||||
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
|
||||||
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &kill_listener])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
|
||||||
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
|
||||||
let fuser_kill = format!(
|
|
||||||
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &fuser_kill])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let pattern = format!("pasta.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn repair_nextcloud_permissions() {
|
async fn repair_nextcloud_permissions() {
|
||||||
@@ -2789,6 +2630,11 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
|
|||||||
| "gitea"
|
| "gitea"
|
||||||
| "portainer"
|
| "portainer"
|
||||||
| "meshtastic"
|
| "meshtastic"
|
||||||
|
// Build-backed user-facing app. Route it through the production
|
||||||
|
// orchestrator so a fresh node builds its bundled image instead
|
||||||
|
// of treating localhost/archipelago-source:local as a registry
|
||||||
|
// image in the legacy installer.
|
||||||
|
| "archipelago-source"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2800,11 +2646,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
|
orchestrator_install_app_id, parse_setup_token, patched_indeedhub_nginx_config,
|
||||||
uses_orchestrator_install_flow,
|
should_try_orchestrator_install, uses_orchestrator_install_flow,
|
||||||
};
|
};
|
||||||
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
|
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]
|
#[test]
|
||||||
fn orchestrator_install_allowlist_includes_ported_backends() {
|
fn orchestrator_install_allowlist_includes_ported_backends() {
|
||||||
for app in [
|
for app in [
|
||||||
@@ -2836,6 +2714,7 @@ mod tests {
|
|||||||
"gitea",
|
"gitea",
|
||||||
"portainer",
|
"portainer",
|
||||||
"meshtastic",
|
"meshtastic",
|
||||||
|
"archipelago-source",
|
||||||
] {
|
] {
|
||||||
assert!(uses_orchestrator_install_flow(app));
|
assert!(uses_orchestrator_install_flow(app));
|
||||||
assert!(should_try_orchestrator_install(app, true));
|
assert!(should_try_orchestrator_install(app, true));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod dependencies;
|
|||||||
mod install;
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod pine_ha;
|
mod pine_ha;
|
||||||
|
pub(crate) use install::patch_indeedhub_nostr_provider;
|
||||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
|||||||
@@ -14,20 +14,23 @@ impl RpcHandler {
|
|||||||
/// the rare case where the pull stream actually parses, but podman
|
/// the rare case where the pull stream actually parses, but podman
|
||||||
/// almost never emits parseable progress on a piped stderr.
|
/// almost never emits parseable progress on a piped stderr.
|
||||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
entry.state = PackageState::Installing;
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
entry.ui_ready = Some(false);
|
||||||
entry.install_progress = Some(InstallProgress {
|
entry.state = PackageState::Installing;
|
||||||
size,
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: existing_phase,
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: existing_phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the install pipeline phase and broadcast. This is the
|
/// Set the install pipeline phase and broadcast. This is the
|
||||||
@@ -35,76 +38,86 @@ impl RpcHandler {
|
|||||||
/// percentage and a user-facing label. Byte counters are retained
|
/// percentage and a user-facing label. Byte counters are retained
|
||||||
/// for the rare case podman emits parseable progress.
|
/// for the rare case podman emits parseable progress.
|
||||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||||
// Updates use Updating state — the wrapper has already flipped
|
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||||
// state to Updating, so don't clobber it.
|
// Updates use Updating state — the wrapper has already flipped
|
||||||
if entry.state != PackageState::Updating {
|
// state to Updating, so don't clobber it.
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded) = entry
|
||||||
.map(|p| (p.size, p.downloaded))
|
.install_progress
|
||||||
.unwrap_or((0, 0));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded))
|
||||||
size,
|
.unwrap_or((0, 0));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: Some(phase),
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: Some(phase),
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||||
/// to start…") without disturbing the current phase/byte counters.
|
/// to start…") without disturbing the current phase/byte counters.
|
||||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
if entry.state != PackageState::Updating {
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded, phase) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded, phase) = entry
|
||||||
.map(|p| (p.size, p.downloaded, p.phase))
|
.install_progress
|
||||||
.unwrap_or((0, 0, None));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded, p.phase))
|
||||||
size,
|
.unwrap_or((0, 0, None));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase,
|
size,
|
||||||
message: Some(message.to_string()),
|
downloaded,
|
||||||
});
|
phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: Some(message.to_string()),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear install progress after pull completes or fails.
|
/// Clear install progress after pull completes or fails.
|
||||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.install_progress = None;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
}
|
entry.install_progress = None;
|
||||||
self.state_manager.update_data(data).await;
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the uninstall stage label so the UI can show what's happening
|
/// Set the uninstall stage label so the UI can show what's happening
|
||||||
/// instead of a generic spinner. Each call broadcasts a state change
|
/// instead of a generic spinner. Each call broadcasts a state change
|
||||||
/// — call sparingly (one per pipeline phase, not per container).
|
/// — call sparingly (one per pipeline phase, not per container).
|
||||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.uninstall_stage = Some(stage.to_string());
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = crate::data_model::PackageState::Removing;
|
entry.uninstall_stage = Some(stage.to_string());
|
||||||
}
|
entry.state = crate::data_model::PackageState::Removing;
|
||||||
self.state_manager.update_data(data).await;
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update install progress (static method for use in async closures).
|
/// Update install progress (static method for use in async closures).
|
||||||
@@ -114,25 +127,28 @@ impl RpcHandler {
|
|||||||
downloaded: u64,
|
downloaded: u64,
|
||||||
total: u64,
|
total: u64,
|
||||||
) {
|
) {
|
||||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.install_progress = Some(InstallProgress {
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
size: total,
|
entry.install_progress = Some(InstallProgress {
|
||||||
downloaded,
|
size: total,
|
||||||
phase: existing_phase,
|
downloaded,
|
||||||
message: None,
|
phase: existing_phase,
|
||||||
});
|
message: None,
|
||||||
state_manager.update_data(data).await;
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a minimal PackageDataEntry for a package being installed.
|
/// Create a minimal PackageDataEntry for a package being installed.
|
||||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Installing,
|
state: PackageState::Installing,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
// A cuprate node that starts on a too-small disk fills it and takes
|
||||||
|
// Archipelago down with it (no upstream pruning — see
|
||||||
|
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||||
|
// before clearing user-stopped or flipping state, so the app stays
|
||||||
|
// cleanly stopped and the error carries the actionable message.
|
||||||
|
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||||
|
|
||||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||||
vec![orchestrator_app_id(package_id).to_string()]
|
vec![orchestrator_app_id(package_id).to_string()]
|
||||||
@@ -251,6 +257,11 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||||
|
// minimum after install it resumes the doomed unprunable sync just like
|
||||||
|
// start would — same gate, same "fail before clearing user-stopped /
|
||||||
|
// flipping state" contract (see handle_package_start).
|
||||||
|
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||||
|
|
||||||
let single_orchestrator_app =
|
let single_orchestrator_app =
|
||||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||||
@@ -1420,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
|
|||||||
// published port and the data-dir file locks, so the replacement either
|
// published port and the data-dir file locks, so the replacement either
|
||||||
// fails to bind (`address already in use`) or starts and dies on the
|
// fails to bind (`address already in use`) or starts and dies on the
|
||||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||||
// the port cleanup below: killing the owner is what actually frees the
|
// starting the replacement. A port sweep cannot distinguish a ghost
|
||||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
// from the dashboard gate or another live app and must never kill it.
|
||||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||||
cleanup_runtime_host_ports(container_name).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_before_package_start(container_name: &str) {
|
async fn wait_before_package_start(container_name: &str) {
|
||||||
@@ -1568,7 +1578,6 @@ async fn repair_netbird_network() {
|
|||||||
async fn repair_nginx_proxy_manager_container() {
|
async fn repair_nginx_proxy_manager_container() {
|
||||||
repair_nginx_proxy_manager_dirs().await;
|
repair_nginx_proxy_manager_dirs().await;
|
||||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||||
cleanup_nginx_proxy_manager_ports().await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||||
cleanup_nginx_proxy_manager_ports().await;
|
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
||||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||||
}
|
}
|
||||||
@@ -1801,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
|
|||||||
|
|
||||||
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
|
dirs.push(root.into());
|
||||||
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
@@ -2021,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ports = runtime_host_ports(container_name);
|
// Only reap processes proven to belong to an absent container. The app
|
||||||
if !ports.is_empty() {
|
// gate shares the app's port on other addresses and lives in this daemon;
|
||||||
cleanup_ports(&ports).await;
|
// killing port owners (or matching argv with pkill) kills the dashboard.
|
||||||
return;
|
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_runtime_host_ports(container_name: &str) {
|
|
||||||
let ports = runtime_host_ports(container_name);
|
|
||||||
if !ports.is_empty() {
|
|
||||||
cleanup_ports(&ports).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_nginx_proxy_manager_ports() {
|
|
||||||
cleanup_ports(&[8081, 8084, 8444]).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_ports(ports: &[u16]) {
|
|
||||||
for port in ports {
|
|
||||||
cleanup_stale_pasta_port(&port.to_string()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
|
||||||
let kill_listener = format!(
|
|
||||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
|
||||||
port
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &kill_listener])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let pattern = format!("pasta.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
let pattern = format!("rootlessport.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||||
@@ -2084,13 +2055,16 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
prev
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the package entry's final state. No-op if the entry has since
|
/// Write the package entry's final state. No-op if the entry has since
|
||||||
@@ -2100,13 +2074,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||||
@@ -2174,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn port_conflict_cleanup_preserves_live_host_listener() {
|
||||||
|
// The previous ss|kill sweep terminated the daemon's app gate on a
|
||||||
|
// restart. Keep a real listening socket owned by this test process.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
cleanup_start_conflict("photoprism", "address already in use").await;
|
||||||
|
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
|
let _connection = listener.accept().await.unwrap();
|
||||||
|
drop(client);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||||
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
||||||
|
|||||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
|||||||
let default = app_catalog::catalog_default_version(app_id);
|
let default = app_catalog::catalog_default_version(app_id);
|
||||||
let cfg = version_config::read(app_id);
|
let cfg = version_config::read(app_id);
|
||||||
let installed = installed_version(app_id).await;
|
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!({
|
Ok(serde_json::json!({
|
||||||
|
"bitcoinPrune": bitcoin_prune,
|
||||||
"id": app_id,
|
"id": app_id,
|
||||||
"supportsVersions": supports_versions(app_id),
|
"supportsVersions": supports_versions(app_id),
|
||||||
"default": default,
|
"default": default,
|
||||||
|
|||||||
@@ -1559,6 +1559,31 @@ impl RpcHandler {
|
|||||||
self.set_install_progress("indeedhub", n_images, n_images)
|
self.set_install_progress("indeedhub", n_images, n_images)
|
||||||
.await;
|
.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
|
// Remove any leftover containers from a previous partial install (or
|
||||||
// from the first-boot frontend stub that used to race the installer).
|
// from the first-boot frontend stub that used to race the installer).
|
||||||
// Without this, `podman run --name indeedhub` fails on name conflict
|
// Without this, `podman run --name indeedhub` fails on name conflict
|
||||||
@@ -1759,7 +1784,7 @@ impl RpcHandler {
|
|||||||
"-e".to_string(),
|
"-e".to_string(),
|
||||||
"NOSTR_JWT_EXPIRES_IN=7d".to_string(),
|
"NOSTR_JWT_EXPIRES_IN=7d".to_string(),
|
||||||
"-e".to_string(),
|
"-e".to_string(),
|
||||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
|
format!("AES_MASTER_SECRET={aes_master}"),
|
||||||
"-e".to_string(),
|
"-e".to_string(),
|
||||||
"ENVIRONMENT=production".to_string(),
|
"ENVIRONMENT=production".to_string(),
|
||||||
format!("{registry}/indeedhub-api:1.0.0"),
|
format!("{registry}/indeedhub-api:1.0.0"),
|
||||||
@@ -1810,7 +1835,7 @@ impl RpcHandler {
|
|||||||
"-e".to_string(),
|
"-e".to_string(),
|
||||||
"ENVIRONMENT=production".to_string(),
|
"ENVIRONMENT=production".to_string(),
|
||||||
"-e".to_string(),
|
"-e".to_string(),
|
||||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
|
format!("AES_MASTER_SECRET={aes_master}"),
|
||||||
format!("{registry}/indeedhub-ffmpeg:1.0.0"),
|
format!("{registry}/indeedhub-ffmpeg:1.0.0"),
|
||||||
],
|
],
|
||||||
&tmp_env,
|
&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);
|
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
|
|
||||||
impl RpcHandler {
|
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.
|
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||||
pub(in crate::api::rpc) async fn handle_package_update(
|
pub(in crate::api::rpc) async fn handle_package_update(
|
||||||
&self,
|
&self,
|
||||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(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
|
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
// 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)
|
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||||
.or_else(|| image_versions::pinned_image_for_app(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`
|
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||||
// (the async wrapper that dispatch actually routes to). By the time
|
// (the async wrapper that dispatch actually routes to). By the time
|
||||||
// this inner function runs, the wrapper has already flipped state to
|
// 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() {
|
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
if let Some(targets) = &targets {
|
||||||
|
verify_update_targets(
|
||||||
|
targets,
|
||||||
|
&inspect_update_images(package_id).await?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||||
.await;
|
.await;
|
||||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).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
|
// 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
|
// Get all containers for this app
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
|||||||
.await;
|
.await;
|
||||||
if let Ok(o) = status {
|
if let Ok(o) = status {
|
||||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||||
if state == "exited" {
|
anyhow::ensure!(
|
||||||
warn!(
|
o.status.success() && state == "running",
|
||||||
"Update {}: container {} exited after recreate",
|
"Update {}: container {} is not running after recreate",
|
||||||
package_id, name
|
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(())
|
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 {
|
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
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 {
|
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||||
matches!(
|
// A primary container already at its target does not mean its backend or
|
||||||
package_id,
|
// database is current. Route every mapped stack through the component flow.
|
||||||
// Multi-container stacks still updated via the stack-aware path.
|
!image_versions::containers_for_stack(package_id).is_empty()
|
||||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
|| 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 {
|
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-bitcoin-ui" => push("bitcoin-ui"),
|
||||||
"archy-lnd-ui" => push("lnd-ui"),
|
"archy-lnd-ui" => push("lnd-ui"),
|
||||||
"archy-electrs-ui" => push("electrs-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("archy-mempool-web");
|
||||||
push("mempool");
|
push("mempool");
|
||||||
}
|
}
|
||||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
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]
|
#[test]
|
||||||
fn legacy_flow_for_stack_apps() {
|
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");
|
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestrator_flow_for_single_apps() {
|
fn orchestrator_flow_for_single_apps() {
|
||||||
for app in [
|
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||||
"lnd",
|
|
||||||
"bitcoin-core",
|
|
||||||
"searxng",
|
|
||||||
"grafana",
|
|
||||||
"btcpay-server",
|
|
||||||
"mempool",
|
|
||||||
"fedimint",
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!uses_legacy_update_flow(app),
|
!uses_legacy_update_flow(app),
|
||||||
"{app} should be orchestrator-first"
|
"{app} should be orchestrator-first"
|
||||||
|
|||||||
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod torrc_tests {
|
mod torrc_tests {
|
||||||
use super::app_hidden_service_port_line;
|
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 {
|
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||||
match name {
|
match name {
|
||||||
"archipelago" => 80,
|
"archipelago" => 80,
|
||||||
"bitcoin" | "bitcoin-knots" => 8333,
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
|
||||||
"electrs" | "electrumx" => 50001,
|
"electrs" | "electrumx" => 50001,
|
||||||
"lnd" => 8080,
|
"lnd" => 8080,
|
||||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
"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 {
|
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
name,
|
||||||
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
|
|||||||
app_id: &str,
|
app_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
prev
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||||
/// removed (e.g. uninstall ran concurrently).
|
/// removed (e.g. uninstall ran concurrently).
|
||||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ impl PortMap {
|
|||||||
/// there.
|
/// there.
|
||||||
fn apps_dirs() -> Vec<PathBuf> {
|
fn apps_dirs() -> Vec<PathBuf> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
|
dirs.push(root.into());
|
||||||
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
|
||||||
|
std::sync::LazyLock::new(tokio::sync::Notify::new);
|
||||||
|
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
|
||||||
|
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
|
||||||
|
|
||||||
|
/// Installation must not wait for the minute sweep before becoming reachable.
|
||||||
|
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
|
||||||
|
pub async fn refresh_now() {
|
||||||
|
let mut completed = REFRESH_REV.subscribe();
|
||||||
|
REFRESH_KICK.notify_one();
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
|
||||||
|
let mut external = false;
|
||||||
|
let mut tor = false;
|
||||||
|
for (claimed_port, address) in &status.claimed {
|
||||||
|
if *claimed_port != port {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(ip) = address.parse::<IpAddr>() {
|
||||||
|
tor |= ip == GATE_TOR_UPSTREAM;
|
||||||
|
external |= !ip.is_loopback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external && tor
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the gate. Returns only on shutdown.
|
/// Run the gate. Returns only on shutdown.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
gate: Arc<AppGate>,
|
gate: Arc<AppGate>,
|
||||||
@@ -162,11 +190,12 @@ pub async fn run(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {}
|
||||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
_ = REFRESH_KICK.notified() => {}
|
||||||
}
|
|
||||||
_ = shutdown_rx.changed() => return,
|
_ = shutdown_rx.changed() => return,
|
||||||
}
|
}
|
||||||
|
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||||
|
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,3 +490,19 @@ mod tests {
|
|||||||
assert!(!status.is_fully_enforced());
|
assert!(!status.is_fully_enforced());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod readiness_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
|
||||||
|
let mut status = GateStatus::default();
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3001, "127.0.0.2".into()));
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3002, "192.0.2.10".into()));
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3001, "192.0.2.10".into()));
|
||||||
|
assert!(port_claimed(&status, 3001));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,9 +148,16 @@ impl AppGate {
|
|||||||
let app = live.as_ref().unwrap_or(app);
|
let app = live.as_ref().unwrap_or(app);
|
||||||
|
|
||||||
let path = req.uri().path().to_string();
|
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) {
|
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
|
// A browser fetches a few subresources WITHOUT credentials by
|
||||||
@@ -188,10 +195,25 @@ impl AppGate {
|
|||||||
return proxy_to_app(req, app, false).await;
|
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 {
|
match self.authorize(req.headers(), &app.app_id).await {
|
||||||
// The credential was a cookie (or none was needed): the
|
// The credential was a cookie (or none was needed): the
|
||||||
// Authorization header, if any, belongs to the app. Forward it.
|
// 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.
|
// The credential WAS the Authorization header, and it was ours.
|
||||||
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
|
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
|
||||||
// 401 rather than a redirect: a redirect to a login page is
|
// 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
|
// clients would follow it and parse HTML as if it were their API
|
||||||
// response. The status says "you are not authenticated" in a way
|
// response. The status says "you are not authenticated" in a way
|
||||||
// every client understands, and browsers still render the body.
|
// 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,
|
app: &GatedPort,
|
||||||
action: &str,
|
action: &str,
|
||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
|
mount_prefix: &str,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
// Assets are GET and pre-auth by nature: the login page cannot
|
// Assets are GET and pre-auth by nature: the login page cannot
|
||||||
// render its own background or logo without them.
|
// render its own background or logo without them.
|
||||||
@@ -236,7 +261,7 @@ impl AppGate {
|
|||||||
return self.serve_asset(name);
|
return self.serve_asset(name);
|
||||||
}
|
}
|
||||||
if req.method() != Method::POST {
|
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
|
// Captured before the body is consumed. The pending-2FA session
|
||||||
@@ -253,17 +278,28 @@ impl AppGate {
|
|||||||
app,
|
app,
|
||||||
Some("Too many attempts. Wait a minute and try again."),
|
Some("Too many attempts. Wait a minute and try again."),
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
mount_prefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let form = match read_form(req).await {
|
let form = match read_form(req).await {
|
||||||
Some(form) => form,
|
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 {
|
match action {
|
||||||
"login" => self.do_login(app, &form, client_ip).await,
|
"login" => self.do_login(app, &form, client_ip, mount_prefix).await,
|
||||||
"totp" => self.do_totp(app, &form, pending, client_ip).await,
|
"totp" => {
|
||||||
|
self.do_totp(app, &form, pending, client_ip, mount_prefix)
|
||||||
|
.await
|
||||||
|
}
|
||||||
_ => not_found(),
|
_ => not_found(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,14 +324,25 @@ impl AppGate {
|
|||||||
.expect("asset response builds")
|
.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();
|
let password = field(form, "password").unwrap_or_default();
|
||||||
|
|
||||||
match self.auth.verify_password(&password).await {
|
match self.auth.verify_password(&password).await {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
_ => {
|
_ => {
|
||||||
self.limiter.record_failure(client_ip).await;
|
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(Some(totp_data)) = self.auth.get_totp_data().await {
|
||||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
|
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
|
||||||
let pending = self.sessions.create_pending(secret).await;
|
let pending = self.sessions.create_pending(secret).await;
|
||||||
let mut resp = totp_page(app, None, StatusCode::OK);
|
let mut resp = totp_page(app, None, StatusCode::OK, mount_prefix);
|
||||||
set_session_cookie(&mut resp, &pending);
|
set_session_cookie(&mut resp, &pending).await;
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,12 +366,13 @@ impl AppGate {
|
|||||||
app,
|
app,
|
||||||
Some("Two-factor data could not be read. Sign in from the dashboard."),
|
Some("Two-factor data could not be read. Sign in from the dashboard."),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
mount_prefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = self.sessions.create().await;
|
let token = self.sessions.create().await;
|
||||||
let mut resp = redirect_to_app();
|
let mut resp = redirect_to_app(mount_prefix);
|
||||||
set_session_cookie(&mut resp, &token);
|
set_session_cookie(&mut resp, &token).await;
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +382,16 @@ impl AppGate {
|
|||||||
form: &Form,
|
form: &Form,
|
||||||
pending: Option<String>,
|
pending: Option<String>,
|
||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
|
mount_prefix: &str,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let code = field(form, "code").unwrap_or_default();
|
let code = field(form, "code").unwrap_or_default();
|
||||||
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
|
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 {
|
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
|
||||||
@@ -345,6 +399,7 @@ impl AppGate {
|
|||||||
app,
|
app,
|
||||||
Some("Session expired. Start again."),
|
Some("Session expired. Start again."),
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
|
mount_prefix,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -371,17 +426,27 @@ impl AppGate {
|
|||||||
}
|
}
|
||||||
match self.sessions.upgrade_to_full(&pending).await {
|
match self.sessions.upgrade_to_full(&pending).await {
|
||||||
Some(full) => {
|
Some(full) => {
|
||||||
let mut resp = redirect_to_app();
|
let mut resp = redirect_to_app(mount_prefix);
|
||||||
set_session_cookie(&mut resp, &full);
|
set_session_cookie(&mut resp, &full).await;
|
||||||
resp
|
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;
|
self.limiter.record_failure(client_ip).await;
|
||||||
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
|
let mut resp = totp_page(
|
||||||
set_session_cookie(&mut resp, &pending);
|
app,
|
||||||
|
Some("Incorrect code."),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
mount_prefix,
|
||||||
|
);
|
||||||
|
set_session_cookie(&mut resp, &pending).await;
|
||||||
resp
|
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,
|
// 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
|
// 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
|
// 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);
|
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()
|
Response::builder()
|
||||||
.status(StatusCode::SEE_OTHER)
|
.status(StatusCode::SEE_OTHER)
|
||||||
.header(header::LOCATION, "/")
|
.header(header::LOCATION, location)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("static response builds")
|
.expect("static response builds")
|
||||||
}
|
}
|
||||||
@@ -674,7 +809,13 @@ dashboard and check {name} under My Apps.</p>"#,
|
|||||||
icon = icon_markup(app),
|
icon = icon_markup(app),
|
||||||
name = esc(&app.app_name),
|
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
|
// Header-based refresh, not <meta> or script: page()'s CSP allows no
|
||||||
// script, and the header keeps the retry out of the document entirely.
|
// script, and the header keeps the retry out of the document entirely.
|
||||||
resp.headers_mut()
|
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
|
/// 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.
|
/// 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.
|
/// 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;
|
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
|
||||||
LOGIN_BACKGROUNDS
|
LOGIN_BACKGROUNDS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -715,7 +856,7 @@ fn background_layers() -> String {
|
|||||||
.map(|(i, name)| {
|
.map(|(i, name)| {
|
||||||
format!(
|
format!(
|
||||||
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
|
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,
|
delay = i as u32 * step,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -938,7 +1079,13 @@ fn base64_encode(bytes: &[u8]) -> String {
|
|||||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
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!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="en"><head>
|
<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),
|
app_name = esc(&app.app_name),
|
||||||
body = body,
|
body = body,
|
||||||
submit_feedback = SUBMIT_FEEDBACK_JS,
|
submit_feedback = SUBMIT_FEEDBACK_JS,
|
||||||
backgrounds = background_layers(),
|
backgrounds = background_layers(mount_prefix),
|
||||||
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
||||||
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
||||||
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
|
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
|
/// 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
|
/// confirm what they are authenticating to rather than being asked for a
|
||||||
/// password by an unexplained page.
|
/// 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!(
|
let body = format!(
|
||||||
r#"{logo}
|
r#"{logo}
|
||||||
{icon}
|
{icon}
|
||||||
@@ -1110,14 +1262,19 @@ fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respo
|
|||||||
err = error
|
err = error
|
||||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||||
.unwrap_or_default(),
|
.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
|
/// Second factor. Reached only after the password verified, and the session
|
||||||
/// backing it cannot authorise anything until this completes.
|
/// 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!(
|
let body = format!(
|
||||||
r#"{icon}
|
r#"{icon}
|
||||||
<h1>Two-factor code</h1>
|
<h1>Two-factor code</h1>
|
||||||
@@ -1133,9 +1290,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
|
|||||||
err = error
|
err = error
|
||||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||||
.unwrap_or_default(),
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -1207,9 +1364,35 @@ mod tests {
|
|||||||
assert_eq!(bearer_token(&headers), None);
|
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]
|
#[tokio::test]
|
||||||
async fn login_page_names_the_app() {
|
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);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
let html = String::from_utf8_lossy(&body);
|
let html = String::from_utf8_lossy(&body);
|
||||||
@@ -1222,7 +1405,7 @@ mod tests {
|
|||||||
async fn page_escapes_app_names() {
|
async fn page_escapes_app_names() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
app.app_name = r#"<script>alert(1)</script>"#.to_string();
|
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 body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
let html = String::from_utf8_lossy(&body);
|
let html = String::from_utf8_lossy(&body);
|
||||||
assert!(!html.contains("<script>alert"));
|
assert!(!html.contains("<script>alert"));
|
||||||
@@ -1235,6 +1418,7 @@ mod tests {
|
|||||||
&app(),
|
&app(),
|
||||||
Some("<img src=x onerror=1>"),
|
Some("<img src=x onerror=1>"),
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"",
|
||||||
);
|
);
|
||||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
let html = String::from_utf8_lossy(&body);
|
let html = String::from_utf8_lossy(&body);
|
||||||
@@ -1293,7 +1477,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
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_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||||
assert!(
|
assert!(
|
||||||
!resp.headers().contains_key("X-Frame-Options"),
|
!resp.headers().contains_key("X-Frame-Options"),
|
||||||
@@ -1329,7 +1513,7 @@ mod tests {
|
|||||||
/// never 404 at all.
|
/// never 404 at all.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_page_sources_its_art_from_the_gate() {
|
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 body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
let html = String::from_utf8_lossy(&body).to_string();
|
let html = String::from_utf8_lossy(&body).to_string();
|
||||||
assert_eq!(
|
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
|
/// The only script the challenge pages may run is the submit-feedback
|
||||||
/// snippet, admitted by hash. The page must carry exactly that script,
|
/// snippet, admitted by hash. The page must carry exactly that script,
|
||||||
/// and the CSP must name its hash — anything injected has a different
|
/// and the CSP must name its hash — anything injected has a different
|
||||||
/// hash and stays inert.
|
/// hash and stays inert.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn submit_feedback_script_is_present_and_hash_pinned() {
|
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"]
|
let csp = resp.headers()["Content-Security-Policy"]
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1455,6 +1658,54 @@ mod tests {
|
|||||||
assert!(headers.get(header::COOKIE).is_none());
|
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.
|
/// The regression that killed every Nostr login on 2026-08-06.
|
||||||
///
|
///
|
||||||
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
|
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
|||||||
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
||||||
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Running,
|
state: PackageState::Running,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@ mod tests {
|
|||||||
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||||
};
|
};
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Running,
|
state: PackageState::Running,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('.');
|
.trim_end_matches('.');
|
||||||
let lower = detail.to_lowercase();
|
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")
|
Some("verifying blocks after restart")
|
||||||
} else if lower.contains("connection reset") {
|
} else if lower.contains("connection reset") {
|
||||||
Some("starting up and not yet accepting RPC connections")
|
Some("starting up and not yet accepting RPC connections")
|
||||||
@@ -340,3 +344,21 @@ mod tests {
|
|||||||
assert!(msg.len() < 260);
|
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");
|
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
|
||||||
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
|
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_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
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.
|
/// 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_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";
|
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
|
/// 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;
|
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 {
|
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
|
||||||
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
|
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
|
||||||
.await
|
.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> {
|
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||||
let content = fs::read_to_string(path)
|
let content = fs::read_to_string(path)
|
||||||
.await
|
.await
|
||||||
@@ -1610,6 +1675,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
|||||||
let missing_v6_https =
|
let missing_v6_https =
|
||||||
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
|
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
|
||||||
let stale_web_search = heal_stale_web_search_block(&content).is_some();
|
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
|
if !missing_app_catalog
|
||||||
&& !missing_bitcoin_status
|
&& !missing_bitcoin_status
|
||||||
&& !missing_lnd_proxy
|
&& !missing_lnd_proxy
|
||||||
@@ -1620,6 +1688,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
|||||||
&& !missing_v6_http
|
&& !missing_v6_http
|
||||||
&& !missing_v6_https
|
&& !missing_v6_https
|
||||||
&& !stale_web_search
|
&& !stale_web_search
|
||||||
|
&& !missing_source_proxy
|
||||||
|
&& !missing_source_prefix
|
||||||
|
&& !missing_nostr_signer
|
||||||
{
|
{
|
||||||
return Ok(false);
|
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) {
|
if let Some(p) = heal_stale_web_search_block(&patched) {
|
||||||
patched = p;
|
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 {
|
if missing_v6_http {
|
||||||
patched = patched.replace(
|
patched = patched.replace(
|
||||||
@@ -1796,6 +1876,17 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
||||||
let outcome = PodmanHealOutcome::Unhealthy;
|
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
|
/// The exact ExecStart framework-pt shipped with must parse, and the
|
||||||
/// rewrite must preserve its listen port and forward target.
|
/// rewrite must preserve its listen port and forward target.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
|||||||
|
|
||||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||||
|
|
||||||
|
/// Smallest disk (GB, total) a cuprate node may be installed, started,
|
||||||
|
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
|
||||||
|
/// pruning (verified against upstream `cuprated/src/config.rs` — the
|
||||||
|
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
|
||||||
|
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
|
||||||
|
/// Monero chain simply does not fit and running it would fill the filesystem
|
||||||
|
/// and take Archipelago down. 450 = chain + growth/headroom: allows
|
||||||
|
/// 500 GB-class disks, refuses the 250 GB VPS class.
|
||||||
|
///
|
||||||
|
/// SINGLE SOURCE OF TRUTH — the RPC gates
|
||||||
|
/// (`api::rpc::package::dependencies`) and the boot reconciler
|
||||||
|
/// (`container::prod_orchestrator`) both read this; a drift between them
|
||||||
|
/// would silently reopen the disk-fill failure the gate exists to close.
|
||||||
|
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
|
||||||
|
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
|
||||||
|
|||||||
@@ -102,6 +102,31 @@ pub struct AppCatalogEntry {
|
|||||||
/// `docs/registry-manifest-design.md`.
|
/// `docs/registry-manifest-design.md`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub manifest: Option<serde_json::Value>,
|
pub manifest: Option<serde_json::Value>,
|
||||||
|
/// Backward-compatible catalog rollout: old daemons ignore these and keep
|
||||||
|
/// the base manifest. New daemons choose only variants they can safely apply.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub manifest_variants: Vec<CatalogManifestVariant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CatalogManifestVariant {
|
||||||
|
pub requires: Vec<String>,
|
||||||
|
pub manifest: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
|
||||||
|
// Never let an unknown future requirement become an unsafe partial match.
|
||||||
|
for variant in entry.manifest_variants.into_iter().rev() {
|
||||||
|
if !variant.requires.is_empty()
|
||||||
|
&& variant
|
||||||
|
.requires
|
||||||
|
.iter()
|
||||||
|
.all(|capability| capability == "runtime-migration-backup-v1")
|
||||||
|
{
|
||||||
|
return Some(variant.manifest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||||
@@ -234,7 +259,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
|||||||
load_catalog()
|
load_catalog()
|
||||||
.apps
|
.apps
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
.filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +425,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) {
|
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||||
// Catalog covers this app with a concrete image -> authoritative.
|
// 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,
|
&catalog_image,
|
||||||
running_image,
|
running_image,
|
||||||
);
|
);
|
||||||
@@ -557,6 +582,32 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"version": "2.45.0", "manifest": {"app": {"id": "portainer", "container": {}}},
|
||||||
|
"manifest_variants": [{"requires": ["runtime-migration-backup-v1"],
|
||||||
|
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
|
||||||
|
});
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OldEntry {
|
||||||
|
manifest: serde_json::Value,
|
||||||
|
}
|
||||||
|
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||||
|
assert!(old.manifest["app"]["container"].get("network").is_none());
|
||||||
|
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||||
|
let chosen = selected_manifest(current).unwrap();
|
||||||
|
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
|
||||||
|
assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
|
||||||
|
let mut future = raw;
|
||||||
|
future["manifest_variants"][0]["requires"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(serde_json::json!("unknown-next-capability"));
|
||||||
|
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
|
||||||
|
assert!(chosen["app"]["container"].get("network").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_and_ignores_unknown_fields() {
|
fn parses_and_ignores_unknown_fields() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||||
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
||||||
|
//! | cuprate | archy-cuprate-ui | Monero node status UI |
|
||||||
//!
|
//!
|
||||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||||
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
|||||||
"lnd" => LND_UI,
|
"lnd" => LND_UI,
|
||||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||||
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
||||||
|
"cuprate" => CUPRATE_UI,
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
|||||||
/// Every companion this build knows how to provision. Kept beside
|
/// Every companion this build knows how to provision. Kept beside
|
||||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||||
/// will not recognise it as one of ours and will leave it running forever.
|
/// will not recognise it as one of ours and will leave it running forever.
|
||||||
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
|
const ALL_COMPANIONS: &[&[CompanionSpec]] =
|
||||||
|
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
|
||||||
|
|
||||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||||
name: "archy-bitcoin-ui",
|
name: "archy-bitcoin-ui",
|
||||||
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
|
|||||||
host_network: true,
|
host_network: true,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||||
|
name: "archy-cuprate-ui",
|
||||||
|
image_base: "cuprate-ui",
|
||||||
|
build_dir_candidates: &[
|
||||||
|
"/opt/archipelago/docker/cuprate-ui",
|
||||||
|
"/home/archipelago/archy/docker/cuprate-ui",
|
||||||
|
"/home/archipelago/Projects/archy/docker/cuprate-ui",
|
||||||
|
],
|
||||||
|
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
|
||||||
|
// secret to inject. Cuprate's restricted RPC (the only thing this UI
|
||||||
|
// proxies) is unauthenticated by design — Monero's safe-for-public
|
||||||
|
// subset — so the nginx.conf is baked into the image.
|
||||||
|
pre_start: None,
|
||||||
|
bind_mounts: &[],
|
||||||
|
ports: &[],
|
||||||
|
host_network: true,
|
||||||
|
}];
|
||||||
|
|
||||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||||
Box::pin(async {
|
Box::pin(async {
|
||||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||||
@@ -292,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
|||||||
/// should reference (`localhost/<base>:latest` for build, registry
|
/// should reference (`localhost/<base>:latest` for build, registry
|
||||||
/// URL for pull).
|
/// URL for pull).
|
||||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
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 local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||||
|
|
||||||
@@ -301,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
|||||||
for dir in spec.build_dir_candidates {
|
for dir in spec.build_dir_candidates {
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
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 {
|
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
|
// changed since it was built. Without this staleness check an
|
||||||
// already-present image is reused forever, so edits to the baked-in
|
// already-present image is reused forever, so edits to the baked-in
|
||||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||||
@@ -828,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
|||||||
if !matches_known_shape {
|
if !matches_known_shape {
|
||||||
return Ok(true);
|
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 {
|
for dir in spec.build_dir_candidates {
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||||
// Conservative on any timeout/error inside: reuse the cache.
|
// 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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||||
@@ -869,6 +915,7 @@ mod tests {
|
|||||||
"mempool-electrs",
|
"mempool-electrs",
|
||||||
"fedimint",
|
"fedimint",
|
||||||
"fedimintd",
|
"fedimintd",
|
||||||
|
"cuprate",
|
||||||
];
|
];
|
||||||
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -893,6 +940,7 @@ mod tests {
|
|||||||
names(&orphan_companions(&[])),
|
names(&orphan_companions(&[])),
|
||||||
vec![
|
vec![
|
||||||
"archy-bitcoin-ui",
|
"archy-bitcoin-ui",
|
||||||
|
"archy-cuprate-ui",
|
||||||
"archy-electrs-ui",
|
"archy-electrs-ui",
|
||||||
"archy-fedimint-ui",
|
"archy-fedimint-ui",
|
||||||
"archy-lnd-ui"
|
"archy-lnd-ui"
|
||||||
@@ -906,7 +954,10 @@ mod tests {
|
|||||||
// electrumx installed, fedimint and lnd not — yet all four companions
|
// electrumx installed, fedimint and lnd not — yet all four companions
|
||||||
// were running because the reconciler was fed the manifest list.
|
// were running because the reconciler was fed the manifest list.
|
||||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
||||||
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
|
assert_eq!(
|
||||||
|
names(&orphans),
|
||||||
|
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -926,12 +977,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
||||||
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
||||||
assert_eq!(orphans.len(), 4);
|
assert_eq!(orphans.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_backend_installed_leaves_no_orphans() {
|
fn every_backend_installed_leaves_no_orphans() {
|
||||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
|
let orphans = orphan_companions(&ids(&[
|
||||||
|
"bitcoin-knots",
|
||||||
|
"lnd",
|
||||||
|
"electrumx",
|
||||||
|
"fedimint",
|
||||||
|
"cuprate",
|
||||||
|
]));
|
||||||
assert!(
|
assert!(
|
||||||
names(&orphans).is_empty(),
|
names(&orphans).is_empty(),
|
||||||
"unexpected orphans: {:?}",
|
"unexpected orphans: {:?}",
|
||||||
@@ -970,7 +1027,12 @@ mod tests {
|
|||||||
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names(&due),
|
names(&due),
|
||||||
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
vec![
|
||||||
|
"archy-cuprate-ui",
|
||||||
|
"archy-electrs-ui",
|
||||||
|
"archy-fedimint-ui",
|
||||||
|
"archy-lnd-ui"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,6 +1086,7 @@ mod tests {
|
|||||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||||
assert_eq!(companions_for("fedimint").len(), 1);
|
assert_eq!(companions_for("fedimint").len(), 1);
|
||||||
assert_eq!(companions_for("fedimintd").len(), 1);
|
assert_eq!(companions_for("fedimintd").len(), 1);
|
||||||
|
assert_eq!(companions_for("cuprate").len(), 1);
|
||||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use archipelago_container::{
|
use archipelago_container::{
|
||||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
|
||||||
};
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
@@ -25,8 +26,15 @@ impl DockerPackageScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan Docker containers and convert to package data
|
/// Scan Docker containers and convert to package data
|
||||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
pub async fn scan_containers(
|
||||||
let containers = self.runtime.list_containers().await?;
|
&self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
cached: &HashMap<String, PackageDataEntry>,
|
||||||
|
) -> Result<HashMap<String, PackageDataEntry>> {
|
||||||
|
let mut containers = self.runtime.list_containers().await?;
|
||||||
|
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
|
||||||
|
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
|
||||||
|
restore_absent_installed(&mut containers, &installed, &uninstalled);
|
||||||
|
|
||||||
debug!("Found {} containers", containers.len());
|
debug!("Found {} containers", containers.len());
|
||||||
|
|
||||||
@@ -139,6 +147,18 @@ impl DockerPackageScanner {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if container.id.is_empty() {
|
||||||
|
if let Some(previous) = cached.get(&app_id) {
|
||||||
|
let mut held = previous.clone();
|
||||||
|
held.state = PackageState::Stopped;
|
||||||
|
held.ui_ready = Some(false);
|
||||||
|
held.health = None;
|
||||||
|
held.exit_code = None;
|
||||||
|
packages.insert(app_id.clone(), held);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get metadata for this app
|
// Get metadata for this app
|
||||||
let metadata = get_app_metadata(&app_id);
|
let metadata = get_app_metadata(&app_id);
|
||||||
// Manifest-owned metadata (icon) wins over the static table: the
|
// Manifest-owned metadata (icon) wins over the static table: the
|
||||||
@@ -179,14 +199,22 @@ impl DockerPackageScanner {
|
|||||||
let tor_address = read_tor_address(&app_id).await;
|
let tor_address = read_tor_address(&app_id).await;
|
||||||
|
|
||||||
// Extract actual version from container image tag
|
// Extract actual version from container image tag
|
||||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
let running_version = if container.id.is_empty() {
|
||||||
|
String::new() // Absence cannot establish the installed image version.
|
||||||
|
} else {
|
||||||
|
image_versions::extract_version_from_image(&container.image)
|
||||||
|
};
|
||||||
|
|
||||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||||
let available_update =
|
let available_update = if container.id.is_empty() {
|
||||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
None
|
||||||
|
} else {
|
||||||
|
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
|
||||||
|
};
|
||||||
|
|
||||||
let package = PackageDataEntry {
|
let package = PackageDataEntry {
|
||||||
|
ui_ready: Some(false),
|
||||||
state: package_state.clone(),
|
state: package_state.clone(),
|
||||||
health: container.health.clone(),
|
health: container.health.clone(),
|
||||||
exit_code: if package_state == PackageState::Exited {
|
exit_code: if package_state == PackageState::Exited {
|
||||||
@@ -283,10 +311,215 @@ impl DockerPackageScanner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let probes: Vec<_> = packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, pkg)| {
|
||||||
|
if pkg.state != PackageState::Running {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = pkg
|
||||||
|
.installed
|
||||||
|
.as_ref()?
|
||||||
|
.interface_addresses
|
||||||
|
.get("main")?
|
||||||
|
.lan_address
|
||||||
|
.clone()?;
|
||||||
|
Some((id.clone(), url))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut results = futures_util::stream::iter(
|
||||||
|
probes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
|
||||||
|
)
|
||||||
|
.buffer_unordered(8);
|
||||||
|
while let Some((id, ready)) = results.next().await {
|
||||||
|
if let Some(pkg) = packages.get_mut(&id) {
|
||||||
|
pkg.ui_ready = Some(ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HTTP on loopback can precede the LAN/Tor listener after install.
|
||||||
|
let port_map = crate::appgate::identity::build_port_map();
|
||||||
|
let gated: Vec<_> = packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, pkg)| {
|
||||||
|
if pkg.ui_ready != Some(true) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = pkg
|
||||||
|
.installed
|
||||||
|
.as_ref()?
|
||||||
|
.interface_addresses
|
||||||
|
.get("main")?
|
||||||
|
.lan_address
|
||||||
|
.as_deref()?;
|
||||||
|
let port = launch_url_port(url)?;
|
||||||
|
port_map
|
||||||
|
.gated(port)
|
||||||
|
.filter(|gate| gate.declared)
|
||||||
|
.map(|_| (id.clone(), port))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !gated.is_empty() {
|
||||||
|
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
|
||||||
|
let status = shared_status();
|
||||||
|
let needs_refresh = {
|
||||||
|
let current = status.read().await;
|
||||||
|
gated.iter().any(|(_, port)| !port_claimed(¤t, *port))
|
||||||
|
};
|
||||||
|
if needs_refresh {
|
||||||
|
refresh_now().await;
|
||||||
|
}
|
||||||
|
let current = status.read().await;
|
||||||
|
for (id, port) in gated {
|
||||||
|
if !port_claimed(¤t, port) {
|
||||||
|
packages.get_mut(&id).unwrap().ui_ready = Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(packages)
|
Ok(packages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
|
||||||
|
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
|
||||||
|
fn restore_absent_installed(
|
||||||
|
containers: &mut Vec<ContainerStatus>,
|
||||||
|
installed: &std::collections::HashSet<String>,
|
||||||
|
uninstalled: &std::collections::HashSet<String>,
|
||||||
|
) {
|
||||||
|
fn canonical(name: &str) -> &str {
|
||||||
|
let name = name.strip_prefix("archy-").unwrap_or(name);
|
||||||
|
match name {
|
||||||
|
"immich_server" => "immich",
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut present: std::collections::HashSet<String> = containers
|
||||||
|
.iter()
|
||||||
|
.map(|c| canonical(&c.name).to_owned())
|
||||||
|
.collect();
|
||||||
|
let removed: std::collections::HashSet<_> =
|
||||||
|
uninstalled.iter().map(|id| canonical(id)).collect();
|
||||||
|
for name in installed {
|
||||||
|
let id = canonical(name);
|
||||||
|
if removed.contains(id) || !present.insert(id.to_owned()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
containers.push(ContainerStatus {
|
||||||
|
id: String::new(),
|
||||||
|
name: id.to_owned(),
|
||||||
|
state: ContainerState::Stopped,
|
||||||
|
health: None,
|
||||||
|
exit_code: None,
|
||||||
|
started_at: None,
|
||||||
|
image: String::new(),
|
||||||
|
created: String::new(),
|
||||||
|
ports: Vec::new(),
|
||||||
|
lan_address: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the actual loopback upstream, not the app gate's login page. A bound
|
||||||
|
/// TCP socket alone can still reset requests or serve a startup 503.
|
||||||
|
async fn launch_http_ready(candidate: &str) -> bool {
|
||||||
|
let Ok(mut url) = reqwest::Url::parse(candidate) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if url.set_host(Some("127.0.0.1")).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||||
|
let client = CLIENT.get_or_init(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
// Self-signed local app certificates are normal. This client only
|
||||||
|
// contacts loopback and never sends credentials or follows redirects.
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.expect("local readiness client")
|
||||||
|
});
|
||||||
|
match client.get(url).send().await {
|
||||||
|
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod lifecycle_regression_tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||||
|
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect();
|
||||||
|
let removed = ["removed".to_owned()].into_iter().collect();
|
||||||
|
let mut containers = Vec::new();
|
||||||
|
restore_absent_installed(&mut containers, &installed, &removed);
|
||||||
|
assert_eq!(containers.len(), 2);
|
||||||
|
assert!(containers
|
||||||
|
.iter()
|
||||||
|
.all(|c| c.state == ContainerState::Stopped));
|
||||||
|
containers[0].state = ContainerState::Running;
|
||||||
|
restore_absent_installed(&mut containers, &installed, &removed);
|
||||||
|
assert_eq!(containers.len(), 2);
|
||||||
|
assert_eq!(containers[0].state, ContainerState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
|
||||||
|
for (status, expected) in [
|
||||||
|
(200, true),
|
||||||
|
(302, true),
|
||||||
|
(401, true),
|
||||||
|
(403, true),
|
||||||
|
(404, false),
|
||||||
|
(500, false),
|
||||||
|
(502, false),
|
||||||
|
(503, false),
|
||||||
|
] {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0; 2048];
|
||||||
|
let n = stream.read(&mut buf).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
|
||||||
|
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
launch_http_ready(&format!("http://localhost:{port}/start")).await,
|
||||||
|
expected,
|
||||||
|
"status {status}"
|
||||||
|
);
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn readiness_rejects_tcp_accept_without_http() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
drop(stream);
|
||||||
|
});
|
||||||
|
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||||
|
task.await.unwrap();
|
||||||
|
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||||
|
assert!(!launch_http_ready("file:///tmp/test").await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AppMetadata {
|
struct AppMetadata {
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
@@ -657,9 +890,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
|||||||
/// Map app_id to Tor hidden service directory name.
|
/// Map app_id to Tor hidden service directory name.
|
||||||
/// "archipelago" is the main web UI (nginx port 80).
|
/// "archipelago" is the main web UI (nginx port 80).
|
||||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
/// 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> {
|
fn tor_service_name(app_id: &str) -> Option<&'static str> {
|
||||||
match app_id {
|
match app_id {
|
||||||
"archipelago" => Some("archipelago"),
|
"archipelago" => Some("archipelago"),
|
||||||
|
"bitcoin-core" => Some("bitcoin-core"),
|
||||||
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
|
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
|
||||||
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
|
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
|
||||||
"lnd" | "lnd-ui" => Some("lnd"),
|
"lnd" | "lnd-ui" => Some("lnd"),
|
||||||
@@ -906,6 +1149,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)]
|
#[cfg(test)]
|
||||||
mod extract_lan_address_tests {
|
mod extract_lan_address_tests {
|
||||||
use super::extract_lan_address;
|
use super::extract_lan_address;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! starting the container with `--config /data/.filebrowser.json`.
|
//! starting the container with `--config /data/.filebrowser.json`.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::update::host_sudo;
|
use crate::update::host_sudo;
|
||||||
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
|
|||||||
s.replace('\'', "'\\''")
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -152,3 +343,231 @@ mod tests {
|
|||||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
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
|
// Match VAR="value" or VAR=value
|
||||||
if let Some((key, val)) = parse_assignment(line) {
|
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);
|
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||||
if key == "ARCHY_REGISTRY" {
|
if key == "ARCHY_REGISTRY" {
|
||||||
registry = expanded.clone();
|
registry = expanded.clone();
|
||||||
@@ -146,6 +152,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
|||||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||||
|
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
|
||||||
|
|
||||||
// Mempool stack (primary = web)
|
// Mempool stack (primary = web)
|
||||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||||
@@ -182,7 +189,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
|||||||
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
|
|
||||||
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
||||||
|
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
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) {
|
if is_floating_tag(&pinned_version) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if matches!(
|
||||||
let running_version = extract_version_from_image(running_image);
|
compare_image_versions(pinned, running_image),
|
||||||
if pinned_version == running_version {
|
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||||
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 p < r {
|
return None;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(pinned_version)
|
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.
|
/// Numeric components of a version tag, for ordering comparisons only.
|
||||||
///
|
///
|
||||||
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
||||||
@@ -423,6 +452,57 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
|
||||||
|
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
|
||||||
|
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
|
||||||
|
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
|
||||||
|
assert_eq!(available_update_for_images(upstream, patch1), None);
|
||||||
|
assert_eq!(available_update_for_images(patch1, patch2), None);
|
||||||
|
assert_eq!(
|
||||||
|
available_update_for_images(patch1, upstream),
|
||||||
|
Some("v3.3.1-archy1".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
available_update_for_images(patch2, patch1),
|
||||||
|
Some("v3.3.1-archy2".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
|
||||||
|
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
|
||||||
|
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
|
||||||
|
assert_eq!(
|
||||||
|
available_catalog_update_for_images(patched, old),
|
||||||
|
Some("v3.3.1-archy1".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
available_catalog_update_for_images(
|
||||||
|
patched,
|
||||||
|
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(available_update_for_images(patched, old), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equivalent_version_spelling_does_not_offer_update() {
|
||||||
|
assert_eq!(
|
||||||
|
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_image_versions() {
|
fn test_parse_image_versions() {
|
||||||
let content = r#"
|
let content = r#"
|
||||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
|||||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
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]
|
#[test]
|
||||||
fn test_image_var_mapping() {
|
fn test_image_var_mapping() {
|
||||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||||
|
|||||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
|||||||
Ok(EnsureOutcome::Written)
|
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<()> {
|
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
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";
|
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||||
if file_exists_as_root(wallet_db).await {
|
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 {
|
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match unlock_existing_wallet().await? {
|
if !bitcoin_rpc_ready().await {
|
||||||
true => {
|
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
return Ok(());
|
||||||
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(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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?;
|
init_wallet_via_rest().await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).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.
|
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||||
const LND_ADMIN_MACAROON: &str =
|
const LND_ADMIN_MACAROON: &str =
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
"/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";
|
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 {
|
async fn file_exists_as_root(path: &str) -> bool {
|
||||||
if std::path::Path::new(path).exists() {
|
if std::path::Path::new(path).exists() {
|
||||||
return true;
|
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 —
|
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||||
// a wrong password still exits on the first pass via `all_rejected`.
|
// a wrong password still exits on the first pass via `all_rejected`.
|
||||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
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;
|
let mut all_rejected = true;
|
||||||
for pw in &candidates {
|
for pw in &candidates {
|
||||||
match try_unlock_once(&client, pw).await {
|
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.
|
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||||
///
|
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||||
/// `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.
|
|
||||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||||
match unlock_existing_wallet().await? {
|
match unlock_existing_wallet().await? {
|
||||||
true => Ok(()),
|
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
|
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
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::Value(seed) => seed,
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,3 +1152,44 @@ mod tests {
|
|||||||
.is_empty());
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Consistent, private snapshots for declaratively opted-in runtime migrations.
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use archipelago_container::AppManifest;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
|
||||||
|
match manifest.app.extensions.get("backup_before_runtime_change") {
|
||||||
|
None => Ok(false),
|
||||||
|
Some(value) => value
|
||||||
|
.as_bool()
|
||||||
|
.context("backup_before_runtime_change must be boolean"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for volume in &manifest.app.volumes {
|
||||||
|
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A runtime socket is a connection, not application state.
|
||||||
|
if volume.source == "/run/user/1000/podman/podman.sock" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if volume.volume_type != "bind" {
|
||||||
|
bail!("runtime migration backup requires bind-mounted persistent state");
|
||||||
|
}
|
||||||
|
let path = Path::new(&volume.source);
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(data_dir)
|
||||||
|
.context("runtime migration state must be inside the node data directory")?;
|
||||||
|
if relative.starts_with("migration-backups") {
|
||||||
|
bail!("migration backup cannot include its own archive directory");
|
||||||
|
}
|
||||||
|
if relative.as_os_str().is_empty()
|
||||||
|
|| relative
|
||||||
|
.components()
|
||||||
|
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||||
|
{
|
||||||
|
bail!("invalid runtime migration state path");
|
||||||
|
}
|
||||||
|
sources.push(relative.to_path_buf());
|
||||||
|
}
|
||||||
|
sources.sort();
|
||||||
|
sources.dedup();
|
||||||
|
let mut roots: Vec<PathBuf> = Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
if !roots.iter().any(|root| source.starts_with(root)) {
|
||||||
|
roots.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roots.is_empty() {
|
||||||
|
bail!("runtime migration backup has no persistent state mounts");
|
||||||
|
}
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller must gracefully stop the app before this function, and resume the old
|
||||||
|
/// service if it fails. No source files are changed or deleted by this operation.
|
||||||
|
pub async fn snapshot(
|
||||||
|
manifest: &AppManifest,
|
||||||
|
data_dir: &Path,
|
||||||
|
previous_unit: Option<&[u8]>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let mut command = tokio::process::Command::new("podman");
|
||||||
|
command.args(["unshare", "tar"]);
|
||||||
|
snapshot_with_command(manifest, data_dir, previous_unit, command).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot_with_command(
|
||||||
|
manifest: &AppManifest,
|
||||||
|
data_dir: &Path,
|
||||||
|
previous_unit: Option<&[u8]>,
|
||||||
|
mut command: tokio::process::Command,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let sources = relative_sources(manifest, data_dir)?;
|
||||||
|
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
|
||||||
|
for source in &sources {
|
||||||
|
let path = data_dir.join(source);
|
||||||
|
if tokio::fs::symlink_metadata(&path)
|
||||||
|
.await?
|
||||||
|
.file_type()
|
||||||
|
.is_symlink()
|
||||||
|
{
|
||||||
|
bail!("runtime migration state mount is a symlink; explicit backup required");
|
||||||
|
}
|
||||||
|
let canonical = tokio::fs::canonicalize(&path).await?;
|
||||||
|
if !canonical.starts_with(&canonical_root) {
|
||||||
|
bail!("runtime migration state path resolves outside node data directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = data_dir.join("migration-backups");
|
||||||
|
tokio::fs::create_dir_all(&root).await?;
|
||||||
|
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
|
||||||
|
let dir = root.join(uuid::Uuid::new_v4().to_string());
|
||||||
|
tokio::fs::create_dir(&dir).await?;
|
||||||
|
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
|
||||||
|
if let Some(unit) = previous_unit {
|
||||||
|
let path = dir.join("previous.container");
|
||||||
|
tokio::fs::write(&path, unit).await?;
|
||||||
|
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||||
|
tokio::fs::File::open(&path).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
let partial = dir.join("state.tar.partial");
|
||||||
|
let archive = dir.join("state.tar");
|
||||||
|
let output = command
|
||||||
|
.args([
|
||||||
|
"--create",
|
||||||
|
"--numeric-owner",
|
||||||
|
"--acls",
|
||||||
|
"--xattrs",
|
||||||
|
"--file",
|
||||||
|
])
|
||||||
|
.arg(&partial)
|
||||||
|
.arg("--directory")
|
||||||
|
.arg(data_dir)
|
||||||
|
.arg("--")
|
||||||
|
.args(&sources)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("start rootless migration snapshot")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
// No tar stderr in public logs: it can contain private filenames.
|
||||||
|
let _ = tokio::fs::remove_file(&partial).await;
|
||||||
|
bail!("persistent-state snapshot failed; original state was left intact");
|
||||||
|
}
|
||||||
|
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
|
||||||
|
tokio::fs::File::open(&partial).await?.sync_all().await?;
|
||||||
|
tokio::fs::rename(&partial, &archive).await?;
|
||||||
|
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
|
||||||
|
"network": manifest.app.container.network, "capabilities": manifest.app.security.capabilities, "sources": sources});
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.join("metadata.json"),
|
||||||
|
serde_json::to_vec_pretty(&metadata)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tokio::fs::File::open(&dir).await?.sync_all().await?;
|
||||||
|
Ok(archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn portainer() -> AppManifest {
|
||||||
|
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = dir.path().join("portainer");
|
||||||
|
tokio::fs::create_dir_all(state.join("compose"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("portainer.db"), b"fixture database")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = state.display().to_string();
|
||||||
|
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||||
|
let archive = snapshot_with_command(
|
||||||
|
&m,
|
||||||
|
dir.path(),
|
||||||
|
Some(b"old unit"),
|
||||||
|
tokio::process::Command::new("tar"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"old unit"
|
||||||
|
);
|
||||||
|
let restored = tempfile::tempdir().unwrap();
|
||||||
|
assert!(tokio::process::Command::new("tar")
|
||||||
|
.arg("-xf")
|
||||||
|
.arg(archive)
|
||||||
|
.arg("-C")
|
||||||
|
.arg(restored.path())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.success());
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(restored.path().join("portainer/portainer.db"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"fixture database"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"services: {}\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||||
|
b"fixture database"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = dir.path().join("portainer");
|
||||||
|
tokio::fs::create_dir_all(state.join("compose"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("portainer.db"), b"unchanged")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = state.display().to_string();
|
||||||
|
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||||
|
assert!(
|
||||||
|
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||||
|
b"unchanged"
|
||||||
|
);
|
||||||
|
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
|
||||||
|
assert!(!entry.unwrap().path().join("state.tar").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
|
||||||
|
let m = portainer();
|
||||||
|
assert!(enabled(&m).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
|
||||||
|
vec![PathBuf::from("portainer")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = "/other/operator/state".into();
|
||||||
|
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||||
|
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
|
||||||
|
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod hooks;
|
|||||||
pub mod image_policy;
|
pub mod image_policy;
|
||||||
pub mod image_versions;
|
pub mod image_versions;
|
||||||
pub mod lnd;
|
pub mod lnd;
|
||||||
|
pub mod migration_backup;
|
||||||
pub mod prod_orchestrator;
|
pub mod prod_orchestrator;
|
||||||
pub mod quadlet;
|
pub mod quadlet;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
|||||||
pub no_new_privileges: bool,
|
pub no_new_privileges: bool,
|
||||||
pub cpu_quota: Option<u32>,
|
pub cpu_quota: Option<u32>,
|
||||||
pub restart_policy: RestartPolicy,
|
pub restart_policy: RestartPolicy,
|
||||||
|
pub stop_grace_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuadletUnit {
|
impl QuadletUnit {
|
||||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
|||||||
let _ = writeln!(s, "[Container]");
|
let _ = writeln!(s, "[Container]");
|
||||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||||
let _ = writeln!(s, "Image={}", self.image);
|
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
|
// Pull=never: companions are pre-pulled or built. A missing image
|
||||||
// must surface as a unit start failure, not a silent retry storm.
|
// must surface as a unit start failure, not a silent retry storm.
|
||||||
let _ = writeln!(s, "Pull=never");
|
let _ = writeln!(s, "Pull=never");
|
||||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
|||||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||||
// not a systemd start gate.
|
// not a systemd start gate.
|
||||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
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
|
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||||
// from saturating the journal. Companions: Always. Backends:
|
// from saturating the journal. Companions: Always. Backends:
|
||||||
// OnFailure (clean stops stay stopped).
|
// OnFailure (clean stops stay stopped).
|
||||||
@@ -376,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
|
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||||
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
|
if p.is_empty()
|
||||||
|
|| p.chars()
|
||||||
|
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
|
||||||
|
{
|
||||||
let escaped = p
|
let escaped = p
|
||||||
.replace('\\', "\\\\")
|
.replace('\\', "\\\\")
|
||||||
.replace('"', "\\\"")
|
.replace('"', "\\\"")
|
||||||
@@ -396,7 +413,7 @@ fn quote_environment(env: &str) -> String {
|
|||||||
if env.is_empty()
|
if env.is_empty()
|
||||||
|| env
|
|| env
|
||||||
.chars()
|
.chars()
|
||||||
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
|
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
|
||||||
{
|
{
|
||||||
let escaped = env
|
let escaped = env
|
||||||
.replace('\\', "\\\\")
|
.replace('\\', "\\\\")
|
||||||
@@ -525,6 +542,9 @@ impl QuadletUnit {
|
|||||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||||
restart_policy: RestartPolicy::Always,
|
restart_policy: RestartPolicy::Always,
|
||||||
|
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||||
|
manifest, name,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,6 +696,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
|||||||
|
|
||||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||||
pub async fn unit_dir() -> Result<PathBuf> {
|
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")
|
let home = std::env::var_os("HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||||
@@ -785,7 +812,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
|||||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
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 {
|
match systemctl_user_status(&["stop", service], timeout).await {
|
||||||
Ok(status) if status.success() => Ok(()),
|
Ok(status) if status.success() => Ok(()),
|
||||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||||
@@ -806,10 +837,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(
|
async fn systemctl_user_status(
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<std::process::ExitStatus> {
|
) -> 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");
|
let mut cmd = Command::new("systemctl");
|
||||||
cmd.arg("--user").args(args);
|
cmd.arg("--user").args(args);
|
||||||
cmd.kill_on_drop(true);
|
cmd.kill_on_drop(true);
|
||||||
@@ -856,6 +906,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
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");
|
let mut cmd = Command::new("systemctl");
|
||||||
cmd.arg("--user").args(args);
|
cmd.arg("--user").args(args);
|
||||||
cmd.kill_on_drop(true);
|
cmd.kill_on_drop(true);
|
||||||
@@ -887,12 +941,77 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
|||||||
!= directive_values(new_body, "HealthRetries=")
|
!= directive_values(new_body, "HealthRetries=")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A unit rewrite and a successful systemd restart are separate operations.
|
||||||
|
/// Keep the restart obligation across errors or a management-daemon restart.
|
||||||
|
pub struct RestartObligation {
|
||||||
|
marker: PathBuf,
|
||||||
|
pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartObligation {
|
||||||
|
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
|
||||||
|
let marker = unit_path.with_extension("restart-pending");
|
||||||
|
if newly_required {
|
||||||
|
// Contents contain no manifest environment or credentials. sync_all
|
||||||
|
// makes the obligation durable before the subsequent unit rename.
|
||||||
|
let file = tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&marker)
|
||||||
|
.await
|
||||||
|
.context("record pending Quadlet restart")?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
if let Some(parent) = marker.parent() {
|
||||||
|
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pending = tokio::fs::try_exists(&marker).await?;
|
||||||
|
Ok(Self { marker, pending })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_pending(&self) -> bool {
|
||||||
|
self.pending
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call only after systemd accepted the replacement service successfully.
|
||||||
|
pub async fn complete(self) -> Result<()> {
|
||||||
|
if self.pending {
|
||||||
|
tokio::fs::remove_file(&self.marker)
|
||||||
|
.await
|
||||||
|
.context("clear completed Quadlet restart")?;
|
||||||
|
if let Some(parent) = self.marker.parent() {
|
||||||
|
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
let old_ports = directive_values(old_body, "PublishPort=");
|
let old_ports = directive_values(old_body, "PublishPort=");
|
||||||
let new_ports = directive_values(new_body, "PublishPort=");
|
let new_ports = directive_values(new_body, "PublishPort=");
|
||||||
old_ports != new_ports
|
old_ports != new_ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
|
[
|
||||||
|
"AddCapability=",
|
||||||
|
"DropCapability=",
|
||||||
|
"NoNewPrivileges=",
|
||||||
|
"ReadOnly=",
|
||||||
|
"User=",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|directive| {
|
||||||
|
let mut old = directive_values(old_body, directive);
|
||||||
|
let mut new = directive_values(new_body, directive);
|
||||||
|
old.sort();
|
||||||
|
new.sort();
|
||||||
|
old != new
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
let old_network = directive_values(old_body, "Network=");
|
let old_network = directive_values(old_body, "Network=");
|
||||||
let new_network = directive_values(new_body, "Network=");
|
let new_network = directive_values(new_body, "Network=");
|
||||||
@@ -923,6 +1042,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
|||||||
/// that systemd no longer knows about.
|
/// that systemd no longer knows about.
|
||||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||||
let svc = format!("{unit_name}.service");
|
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
|
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||||
// rootless podman a generated unit can wedge in "deactivating" while
|
// rootless podman a generated unit can wedge in "deactivating" while
|
||||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||||
@@ -930,13 +1053,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
|
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||||
// blocks reinstall). If the graceful stop times out, escalate to
|
// blocks reinstall). If the graceful stop times out, escalate to
|
||||||
// SIGKILL + reset-failed so teardown always proceeds.
|
// SIGKILL + reset-failed so teardown always proceeds.
|
||||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
if systemctl_user_status(&["stop", &svc], timeout)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
let _ = kill_and_reset_service(&svc).await;
|
let _ = kill_and_reset_service(&svc).await;
|
||||||
}
|
}
|
||||||
let path = dir.join(format!("{unit_name}.container"));
|
|
||||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -949,9 +1071,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
|
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||||
// exists to avoid.
|
// exists to avoid.
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
QUADLET_STOP_TIMEOUT,
|
timeout,
|
||||||
Command::new("podman")
|
Command::new("podman")
|
||||||
.args(["rm", "-f", unit_name])
|
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||||
.status(),
|
.status(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -960,6 +1082,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
/// Is the quadlet-generated service currently active?
|
/// Is the quadlet-generated service currently active?
|
||||||
pub async fn is_active(service: &str) -> bool {
|
pub async fn is_active(service: &str) -> bool {
|
||||||
|
if cfg!(test) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Command::new("systemctl")
|
Command::new("systemctl")
|
||||||
.args(["--user", "is-active", "--quiet", service])
|
.args(["--user", "is-active", "--quiet", service])
|
||||||
.status()
|
.status()
|
||||||
@@ -973,6 +1098,118 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
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]
|
#[test]
|
||||||
fn render_emits_secret_env_by_reference_never_value() {
|
fn render_emits_secret_env_by_reference_never_value() {
|
||||||
let u = QuadletUnit {
|
let u = QuadletUnit {
|
||||||
@@ -1160,6 +1397,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apostrophes_survive_quadlet_argument_and_environment_parsing() {
|
||||||
|
// A whitespace-free Node script reproduced this in a real Quadlet:
|
||||||
|
// unquoted apostrophes were consumed by the parser, changing JS strings
|
||||||
|
// into identifiers and preventing the app from starting.
|
||||||
|
assert_eq!(
|
||||||
|
shell_join(&["require('http')".into()]),
|
||||||
|
"\"require('http')\""
|
||||||
|
);
|
||||||
|
assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\"");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quote_environment_quotes_values_with_spaces() {
|
fn quote_environment_quotes_values_with_spaces() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1372,6 +1621,26 @@ app:
|
|||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||||
|
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
||||||
|
.expect("shipped Portainer manifest must parse");
|
||||||
|
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
|
||||||
|
assert!(new.contains("Network=slirp4netns\n"));
|
||||||
|
assert!(!new.contains("NetworkAlias="));
|
||||||
|
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
|
||||||
|
assert!(!new.contains("PublishPort=0.0.0.0"));
|
||||||
|
// The upgrade changes networking only: retain both state mounts and the
|
||||||
|
// existing rootless socket, without an app.ini or repository rewrite.
|
||||||
|
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
|
||||||
|
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
|
||||||
|
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
|
||||||
|
let old = new.replace("Network=slirp4netns\n", "");
|
||||||
|
assert!(network_aliases_changed(&old, &new));
|
||||||
|
assert!(!network_aliases_changed(&new, &new));
|
||||||
|
assert!(!publish_ports_changed(&old, &new));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_manifest_slirp4netns_omits_network_alias() {
|
fn from_manifest_slirp4netns_omits_network_alias() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -1722,6 +1991,59 @@ app:
|
|||||||
assert!(!network_aliases_changed(new, new));
|
assert!(!network_aliases_changed(new, new));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let unit = dir.path().join("portainer.container");
|
||||||
|
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
|
||||||
|
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||||
|
assert!(pending.is_pending());
|
||||||
|
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Simulate systemctl failure or daemon interruption after unit rewrite.
|
||||||
|
drop(pending);
|
||||||
|
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
retry.is_pending(),
|
||||||
|
"matching unit must not discard failed restart"
|
||||||
|
);
|
||||||
|
retry.complete().await.unwrap();
|
||||||
|
assert!(!RestartObligation::prepare(&unit, false)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_runtime_change_errors_are_not_reported_as_success() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let missing = dir.path().join("missing/app.container");
|
||||||
|
assert!(RestartObligation::prepare(&missing, true).await.is_err());
|
||||||
|
let unit = dir.path().join("app.container");
|
||||||
|
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||||
|
tokio::fs::remove_file(unit.with_extension("restart-pending"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(pending.complete().await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
|
||||||
|
let manifest =
|
||||||
|
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
|
||||||
|
manifest.validate().unwrap();
|
||||||
|
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
|
||||||
|
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
|
||||||
|
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
|
||||||
|
assert!(security_changed(&old, &new));
|
||||||
|
assert!(!security_changed(&new, &new));
|
||||||
|
assert!(!security_changed(
|
||||||
|
"AddCapability=CHOWN\nAddCapability=SETUID\n",
|
||||||
|
"AddCapability=SETUID\nAddCapability=CHOWN\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_aliases_changed_detects_network_mode_drift() {
|
fn network_aliases_changed_detects_network_mode_drift() {
|
||||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||||
|
|||||||
@@ -140,6 +140,79 @@ fn random_base64(bytes: usize) -> String {
|
|||||||
/// daemon read `fedimint-gateway-hash`).
|
/// daemon read `fedimint-gateway-hash`).
|
||||||
pub const GATEWAY_HASH_SECRET_NAME: &str = "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
|
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
|
||||||
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
|
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
|
||||||
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
|
/// /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]
|
#[test]
|
||||||
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
|
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ pub enum ServeResult {
|
|||||||
Forbidden,
|
Forbidden,
|
||||||
/// Content not found.
|
/// Content not found.
|
||||||
NotFound,
|
NotFound,
|
||||||
|
/// The catalog entry and file exist but this node can't read the file.
|
||||||
|
/// Returned before any payment is taken.
|
||||||
|
Unavailable,
|
||||||
|
/// Requested byte range cannot be served; no payment was taken.
|
||||||
|
RangeNotSatisfiable(u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve a content item by ID with access control and optional range request.
|
/// Serve a content item by ID with access control and optional range request.
|
||||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
|||||||
range: Option<ByteRange>,
|
range: Option<ByteRange>,
|
||||||
owner_session: bool,
|
owner_session: bool,
|
||||||
) -> Result<ServeResult> {
|
) -> Result<ServeResult> {
|
||||||
|
serve_content_with(
|
||||||
|
data_dir,
|
||||||
|
id,
|
||||||
|
payment_token,
|
||||||
|
invoice_hash,
|
||||||
|
peer_did,
|
||||||
|
range,
|
||||||
|
owner_session,
|
||||||
|
|path, range, mime| prepare_content(data_dir, path, range, mime),
|
||||||
|
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject only the read and payment boundaries, so tests can prove ordering
|
||||||
|
// without mint access, file-permission assumptions or privileged commands.
|
||||||
|
async fn serve_content_with<R, RF, V, VF>(
|
||||||
|
data_dir: &Path,
|
||||||
|
id: &str,
|
||||||
|
payment_token: Option<&str>,
|
||||||
|
invoice_hash: Option<&str>,
|
||||||
|
peer_did: Option<&str>,
|
||||||
|
range: Option<ByteRange>,
|
||||||
|
owner_session: bool,
|
||||||
|
read: R,
|
||||||
|
verify: V,
|
||||||
|
) -> Result<ServeResult>
|
||||||
|
where
|
||||||
|
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
|
||||||
|
RF: std::future::Future<Output = Result<ServeResult>>,
|
||||||
|
V: FnOnce(String, u64) -> VF,
|
||||||
|
VF: std::future::Future<Output = bool>,
|
||||||
|
{
|
||||||
let catalog = load_catalog(data_dir).await?;
|
let catalog = load_catalog(data_dir).await?;
|
||||||
let item = match catalog.items.iter().find(|i| i.id == id) {
|
let item = match catalog.items.iter().find(|i| i.id == id) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
@@ -296,6 +334,47 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse unauthorized viewers before opening or reading any bytes.
|
||||||
|
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
|
||||||
|
return Ok(ServeResult::Forbidden);
|
||||||
|
}
|
||||||
|
if !owner_session {
|
||||||
|
if let AccessControl::Paid { price_sats, .. } = &item.access {
|
||||||
|
if payment_token.is_none() && invoice_hash.is_none() {
|
||||||
|
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish all file I/O before consuming bearer payment. Merely opening then
|
||||||
|
// reopening after charging still lost payments on read errors or deletion.
|
||||||
|
let prepared = match read(file_path, range, item.mime_type.clone()).await {
|
||||||
|
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
|
||||||
|
Ok(other) => return Ok(other),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
|
||||||
|
return Ok(ServeResult::Unavailable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check access control
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -307,9 +386,13 @@ pub async fn serve_content(
|
|||||||
// Each path only counts when the sharer accepts that method.
|
// Each path only counts when the sharer accepts that method.
|
||||||
let mut authorized = false;
|
let mut authorized = false;
|
||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if (method_accepted(&item.access, "ecash")
|
let method = if token.trim().starts_with("cashu") {
|
||||||
|| method_accepted(&item.access, "fedimint"))
|
"ecash"
|
||||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
} else {
|
||||||
|
"fedimint"
|
||||||
|
};
|
||||||
|
if method_accepted(&item.access, method)
|
||||||
|
&& verify(token.to_owned(), *price_sats).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
@@ -336,73 +419,127 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_path = content_file_path(data_dir, item);
|
Ok(prepared)
|
||||||
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)
|
async fn prepare_content(
|
||||||
|
data_dir: &Path,
|
||||||
|
path: PathBuf,
|
||||||
|
range: Option<ByteRange>,
|
||||||
|
mime: String,
|
||||||
|
) -> Result<ServeResult> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
let mut file = match fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(&path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
{
|
||||||
let total_size = metadata.len();
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
// Handle range request for streaming
|
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||||
if let Some(range) = range {
|
return slice_prepared_content(bytes, range, mime);
|
||||||
let start = range.start.min(total_size.saturating_sub(1));
|
|
||||||
let end = range
|
|
||||||
.end
|
|
||||||
.map(|e| e.min(total_size - 1))
|
|
||||||
.unwrap_or(total_size - 1);
|
|
||||||
|
|
||||||
if start > end || start >= total_size {
|
|
||||||
return Ok(ServeResult::NotFound);
|
|
||||||
}
|
}
|
||||||
|
Err(error) => return Err(error).context("Opening shared content"),
|
||||||
let len = (end - start + 1) as usize;
|
};
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
let metadata = file.metadata().await?;
|
||||||
let mut file = tokio::fs::File::open(&file_path)
|
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
|
||||||
|
let total = metadata.len();
|
||||||
|
if let Some(range) = range {
|
||||||
|
let Some((start, end)) = checked_range(&range, total) else {
|
||||||
|
return Ok(ServeResult::RangeNotSatisfiable(total));
|
||||||
|
};
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
|
||||||
|
let mut bytes = vec![0; len];
|
||||||
|
file.read_exact(&mut bytes)
|
||||||
.await
|
.await
|
||||||
.context("Failed to open content file")?;
|
.context("Reading shared content range")?;
|
||||||
file.seek(std::io::SeekFrom::Start(start))
|
|
||||||
.await
|
|
||||||
.context("Failed to seek")?;
|
|
||||||
let mut buf = vec![0u8; len];
|
|
||||||
file.read_exact(&mut buf)
|
|
||||||
.await
|
|
||||||
.context("Failed to read range")?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Serving content '{}' range {}-{}/{} ({} bytes)",
|
|
||||||
id, start, end, total_size, len
|
|
||||||
);
|
|
||||||
return Ok(ServeResult::Partial {
|
return Ok(ServeResult::Partial {
|
||||||
bytes: buf,
|
bytes,
|
||||||
mime_type: item.mime_type.clone(),
|
mime_type: mime,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
total: total_size,
|
total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
let bytes = fs::read(&file_path)
|
file.read_to_end(&mut bytes)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read content file")?;
|
.context("Reading shared content")?;
|
||||||
|
Ok(ServeResult::Ok(bytes, mime))
|
||||||
|
}
|
||||||
|
|
||||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
let last = total.checked_sub(1)?;
|
||||||
|
let end = range.end.unwrap_or(last).min(last);
|
||||||
|
(range.start <= end && range.start < total).then_some((range.start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_prepared_content(
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
range: Option<ByteRange>,
|
||||||
|
mime: String,
|
||||||
|
) -> Result<ServeResult> {
|
||||||
|
let total = bytes.len() as u64;
|
||||||
|
match range {
|
||||||
|
None => Ok(ServeResult::Ok(bytes, mime)),
|
||||||
|
Some(range) => match checked_range(&range, total) {
|
||||||
|
Some((start, end)) => Ok(ServeResult::Partial {
|
||||||
|
bytes: bytes[start as usize..=end as usize].to_vec(),
|
||||||
|
mime_type: mime,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
total,
|
||||||
|
}),
|
||||||
|
None => Ok(ServeResult::RangeNotSatisfiable(total)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read only an explicitly shared, regular file within FileBrowser storage.
|
||||||
|
/// Do not change its mode or grant world-readable access to paid/private data.
|
||||||
|
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
|
||||||
|
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
|
||||||
|
let target = fs::canonicalize(path).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
target.starts_with(&root) && target != root,
|
||||||
|
"Shared file is outside Files storage"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
fs::metadata(&target).await?.is_file(),
|
||||||
|
"Shared content is not a regular file"
|
||||||
|
);
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let path = filebrowser_read_path(data_dir, path).await?;
|
||||||
|
// Tests exercise the boundary explicitly; they never launch the host Podman.
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
anyhow::bail!("Files namespace read disabled in unit tests")
|
||||||
|
}
|
||||||
|
#[cfg(not(test))]
|
||||||
|
{
|
||||||
|
let output = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(900),
|
||||||
|
tokio::process::Command::new("podman")
|
||||||
|
.args(["unshare", "cat", "--"])
|
||||||
|
.arg(path)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("Files namespace read timed out")??;
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"Files namespace read failed: {}",
|
||||||
|
output.status
|
||||||
|
);
|
||||||
|
Ok(output.stdout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of attempting to serve a preview.
|
/// Result of attempting to serve a preview.
|
||||||
@@ -573,7 +710,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a payment token covers the required amount.
|
/// 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.
|
/// 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 {
|
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 {
|
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||||
@@ -725,3 +862,301 @@ mod prune_missing_content_tests {
|
|||||||
assert_eq!(reloaded.items[0].id, "present-item");
|
assert_eq!(reloaded.items[0].id, "present-item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod paid_read_order_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir_all(dir.path().join("content/files"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.path().join("content/files/test.bin"), bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
save_catalog(
|
||||||
|
dir.path(),
|
||||||
|
&ContentCatalog {
|
||||||
|
items: vec![ContentItem {
|
||||||
|
id: "paid".into(),
|
||||||
|
filename: "test.bin".into(),
|
||||||
|
mime_type: "application/octet-stream".into(),
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
description: String::new(),
|
||||||
|
access: AccessControl::Paid {
|
||||||
|
price_sats: 10,
|
||||||
|
accepted: vec!["ecash".into()],
|
||||||
|
},
|
||||||
|
availability: Availability::AllPeers,
|
||||||
|
added_at: "2026-09-30".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn all_read_failures_precede_redemption_even_as_root() {
|
||||||
|
for kind in [
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
] {
|
||||||
|
let dir = fixture(b"abc").await;
|
||||||
|
let charged = AtomicUsize::new(0);
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|
||||||
|
|_, _| async {
|
||||||
|
charged.fetch_add(1, Ordering::SeqCst);
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Unavailable));
|
||||||
|
assert_eq!(charged.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
|
||||||
|
let dir = fixture(b"original").await;
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, amount| {
|
||||||
|
assert_eq!(amount, 10);
|
||||||
|
async {
|
||||||
|
fs::remove_file(dir.path().join("content/files/test.bin"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
|
||||||
|
for (bytes, start, end) in [
|
||||||
|
(b"".as_slice(), 0, None),
|
||||||
|
(b"abc".as_slice(), 3, None),
|
||||||
|
(b"abc".as_slice(), 2, Some(1)),
|
||||||
|
] {
|
||||||
|
let dir = fixture(bytes).await;
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ByteRange { start, end }),
|
||||||
|
false,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, _| async { panic!("invalid range reached payment") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prepared_range_survives_file_change_while_payment_is_verified() {
|
||||||
|
let dir = fixture(b"abcdef").await;
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ByteRange {
|
||||||
|
start: 2,
|
||||||
|
end: Some(999),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, _| async {
|
||||||
|
fs::write(dir.path().join("content/files/test.bin"), b"x")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_denial_never_returns_prepared_content() {
|
||||||
|
let dir = fixture(b"secret").await;
|
||||||
|
let charged = AtomicUsize::new(0);
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, _| async {
|
||||||
|
charged.fetch_add(1, Ordering::SeqCst);
|
||||||
|
false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||||
|
assert_eq!(charged.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
|
||||||
|
let dir = fixture(b"secret").await;
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|_, _, _| async { panic!("unauthorized file read") },
|
||||||
|
|_, _| async { panic!("unexpected payment") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||||
|
let mut catalog = load_catalog(dir.path()).await.unwrap();
|
||||||
|
catalog.items[0].access = AccessControl::PeersOnly;
|
||||||
|
save_catalog(dir.path(), &catalog).await.unwrap();
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|_, _, _| async { panic!("unauthorized file read") },
|
||||||
|
|_, _| async { panic!("unexpected payment") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_reads_paid_content_without_redemption() {
|
||||||
|
let dir = fixture(b"own file").await;
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, _| async { panic!("owner charged") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn directory_in_place_of_file_does_not_charge() {
|
||||||
|
let dir = fixture(b"abc").await;
|
||||||
|
let path = dir.path().join("content/files/test.bin");
|
||||||
|
fs::remove_file(&path).await.unwrap();
|
||||||
|
fs::create_dir(&path).await.unwrap();
|
||||||
|
let result = serve_content_with(
|
||||||
|
dir.path(),
|
||||||
|
"paid",
|
||||||
|
Some("cashuBtest"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||||
|
|_, _| async { panic!("directory charged") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Unavailable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
|
||||||
|
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||||
|
let dir = fixture(b"outside").await;
|
||||||
|
let root = dir.path().join("filebrowser");
|
||||||
|
fs::create_dir(&root).await.unwrap();
|
||||||
|
let inside = root.join("song");
|
||||||
|
fs::write(&inside, b"song").await.unwrap();
|
||||||
|
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
|
||||||
|
inside
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
|
||||||
|
0o640
|
||||||
|
);
|
||||||
|
let outside = dir.path().join("content/files/test.bin");
|
||||||
|
symlink(&outside, root.join("escape")).unwrap();
|
||||||
|
for path in [outside, root.join("escape"), root.clone()] {
|
||||||
|
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_namespace_bytes_use_the_same_range_rules() {
|
||||||
|
assert!(matches!(
|
||||||
|
slice_prepared_content(
|
||||||
|
vec![],
|
||||||
|
Some(ByteRange {
|
||||||
|
start: 0,
|
||||||
|
end: None
|
||||||
|
}),
|
||||||
|
"x".into()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ServeResult::RangeNotSatisfiable(0)
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
|||||||
// Installation is a decision, not a runtime observation, so it gets a record
|
// Installation is a decision, not a runtime observation, so it gets a record
|
||||||
// of its own that no amount of downtime erodes.
|
// of its own that no amount of downtime erodes.
|
||||||
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
|
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
|
||||||
|
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
/// Load the durable set of installed app ids / container names.
|
/// Load the durable set of installed app ids / container names.
|
||||||
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
|
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||||
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
|
|||||||
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
||||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||||
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
||||||
let _ = fs::write(&path, json).await;
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
let result = async {
|
||||||
|
fs::write(&tmp, json).await?;
|
||||||
|
fs::File::open(&tmp).await?.sync_all().await?;
|
||||||
|
fs::rename(&tmp, &path).await?;
|
||||||
|
fs::File::open(data_dir).await?.sync_all().await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
warn!(%error, "could not persist installed apps");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that an app is installed. Called when an install succeeds.
|
/// Record that an app is installed. Called when an install succeeds.
|
||||||
pub async fn mark_installed(data_dir: &Path, name: &str) {
|
pub async fn mark_installed(data_dir: &Path, name: &str) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
let mut installed = load_installed_apps(data_dir).await;
|
let mut installed = load_installed_apps(data_dir).await;
|
||||||
if installed.insert(name.to_string()) {
|
if installed.insert(name.to_string()) {
|
||||||
save_installed_apps(data_dir, &installed).await;
|
save_installed_apps(data_dir, &installed).await;
|
||||||
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
|
|||||||
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
|
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
|
||||||
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
||||||
pub async fn clear_installed(data_dir: &Path, name: &str) {
|
pub async fn clear_installed(data_dir: &Path, name: &str) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
let mut installed = load_installed_apps(data_dir).await;
|
let mut installed = load_installed_apps(data_dir).await;
|
||||||
if installed.remove(name) {
|
if installed.remove(name) {
|
||||||
save_installed_apps(data_dir, &installed).await;
|
save_installed_apps(data_dir, &installed).await;
|
||||||
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
|
|||||||
/// need it. Runs on every boot, so an app installed before the upgrade is
|
/// need it. Runs on every boot, so an app installed before the upgrade is
|
||||||
/// still picked up whenever it is next seen alive.
|
/// still picked up whenever it is next seen alive.
|
||||||
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
|
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
if present_container_names.is_empty() {
|
if present_container_names.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1497,3 +1511,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod installed_concurrency_tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_install_records_are_not_lost() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for i in 0..24 {
|
||||||
|
let path = dir.path().to_owned();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
mark_installed(&path, &format!("app-{i}")).await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
|
||||||
|
clear_installed(dir.path(), "app-3").await;
|
||||||
|
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
|
||||||
|
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ pub enum PackageState {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct PackageDataEntry {
|
pub struct PackageDataEntry {
|
||||||
|
/// Whether the app's HTTP upstream answered this scan (independent of
|
||||||
|
/// container health and blockchain sync). Missing on older nodes.
|
||||||
|
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_ready: Option<bool>,
|
||||||
pub state: PackageState,
|
pub state: PackageState,
|
||||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -297,6 +301,8 @@ pub enum InstallPhase {
|
|||||||
/// `podman pull` in progress (the longest phase — up to several
|
/// `podman pull` in progress (the longest phase — up to several
|
||||||
/// minutes for large images on slow networks).
|
/// minutes for large images on slow networks).
|
||||||
PullingImage,
|
PullingImage,
|
||||||
|
/// Orchestrator owns download/build and startup as one operation.
|
||||||
|
PreparingApp,
|
||||||
/// Creating data directories, writing app-specific configs
|
/// Creating data directories, writing app-specific configs
|
||||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||||
CreatingContainer,
|
CreatingContainer,
|
||||||
|
|||||||
@@ -5,8 +5,45 @@
|
|||||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||||
//! no listener, so allowing them is inert.
|
//! no listener, so allowing them is inert.
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||||
2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
|
2283,
|
||||||
8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
2342,
|
||||||
18081, 18083, 23000, 32838, 50002,
|
3000,
|
||||||
|
3001,
|
||||||
|
3002,
|
||||||
|
4080,
|
||||||
|
5180,
|
||||||
|
7778,
|
||||||
|
8080,
|
||||||
|
8081,
|
||||||
|
8082,
|
||||||
|
8083,
|
||||||
|
8084,
|
||||||
|
8085,
|
||||||
|
8087,
|
||||||
|
8090,
|
||||||
|
8091,
|
||||||
|
8096,
|
||||||
|
8123,
|
||||||
|
8175,
|
||||||
|
8176,
|
||||||
|
8187,
|
||||||
|
8240,
|
||||||
|
8334,
|
||||||
|
8336,
|
||||||
|
8337,
|
||||||
|
8888,
|
||||||
|
8998,
|
||||||
|
8999,
|
||||||
|
9000,
|
||||||
|
9100,
|
||||||
|
10380,
|
||||||
|
11434,
|
||||||
|
18081,
|
||||||
|
18083,
|
||||||
|
18091,
|
||||||
|
23000,
|
||||||
|
32838,
|
||||||
|
50002,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
|||||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is this FIPS answer the final one, or should the request go again over
|
||||||
|
/// Tor? A single-delivery request already reached the peer, so any answer
|
||||||
|
/// is final: a Tor replay would carry the same (possibly spent) payload.
|
||||||
|
fn fips_answer_is_final(
|
||||||
|
pref: crate::settings::transport::TransportPref,
|
||||||
|
single_delivery: bool,
|
||||||
|
status: reqwest::StatusCode,
|
||||||
|
) -> bool {
|
||||||
|
pref == crate::settings::transport::TransportPref::Fips
|
||||||
|
|| single_delivery
|
||||||
|
|| !fips_should_fall_back(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
|
||||||
|
/// peer never saw it; a timeout can land after the request was delivered.
|
||||||
|
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
|
||||||
|
e.is_connect() || (!single_delivery && e.is_timeout())
|
||||||
|
}
|
||||||
|
|
||||||
/// DNS suffix appended to a peer's bech32 npub.
|
/// DNS suffix appended to a peer's bech32 npub.
|
||||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||||
|
|
||||||
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
|
|||||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||||
|
client_with_delivery_policy(timeout, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
|
||||||
|
if single {
|
||||||
|
reqwest::redirect::Policy::none()
|
||||||
|
} else {
|
||||||
|
reqwest::redirect::Policy::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.redirect(delivery_redirect_policy(single))
|
||||||
.timeout(timeout)
|
.timeout(timeout)
|
||||||
.connect_timeout(Duration::from_secs(8))
|
.connect_timeout(Duration::from_secs(8))
|
||||||
.user_agent("archipelago-fips/1")
|
.user_agent("archipelago-fips/1")
|
||||||
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
|||||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
|
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
|
||||||
|
async fn send_with_retry_if(
|
||||||
|
rb: reqwest::RequestBuilder,
|
||||||
|
retryable: impl Fn(&reqwest::Error) -> bool,
|
||||||
|
) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
let retry = rb.try_clone();
|
let retry = rb.try_clone();
|
||||||
match rb.send().await {
|
match rb.send().await {
|
||||||
Ok(resp) => Ok(resp),
|
Ok(resp) => Ok(resp),
|
||||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
Err(e) if retryable(&e) && retry.is_some() => {
|
||||||
// Brief pause so the hole-punch packets from the first attempt can
|
// Brief pause so the hole-punch packets from the first attempt can
|
||||||
// traverse before we re-dial onto the warmed path.
|
// traverse before we re-dial onto the warmed path.
|
||||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||||
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
|
|||||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||||
/// every caller has a data dir in scope.
|
/// every caller has a data dir in scope.
|
||||||
pub record_data_dir: Option<std::path::PathBuf>,
|
pub record_data_dir: Option<std::path::PathBuf>,
|
||||||
|
/// The request carries something that must reach the peer at most once
|
||||||
|
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
|
||||||
|
pub single_delivery: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PeerRequest<'a> {
|
impl<'a> PeerRequest<'a> {
|
||||||
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
|
|||||||
fips_timeout: None,
|
fips_timeout: None,
|
||||||
service: None,
|
service: None,
|
||||||
record_data_dir: None,
|
record_data_dir: None,
|
||||||
|
single_delivery: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Never send this request twice. A paid download carries a bearer ecash
|
||||||
|
/// token that the seller redeems on first sight; replaying it over Tor
|
||||||
|
/// after FIPS already delivered it hands the seller a spent token, so the
|
||||||
|
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
|
||||||
|
/// answered 404 after the seller redeemed, the Tor retry got 402).
|
||||||
|
///
|
||||||
|
/// With this set, whatever FIPS answers is final, the FIPS retry fires
|
||||||
|
/// only when the first attempt never connected, and Tor is used only when
|
||||||
|
/// FIPS could not have delivered the request. An attempt that may have
|
||||||
|
/// been delivered but timed out is an error, not a fallback.
|
||||||
|
pub fn single_delivery(mut self) -> Self {
|
||||||
|
self.single_delivery = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Record the transport that serves this request into federation storage
|
/// Record the transport that serves this request into federation storage
|
||||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
/// (matched by this request's onion host). Best-effort, off the hot path.
|
||||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
// Use the FIPS reply unless it's one a Tor retry could
|
// Use the FIPS reply unless it's one a Tor retry could
|
||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
// fall back. FIPS-only never falls back.
|
// fall back. FIPS-only never falls back.
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
telemetry::record_fips_ok();
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||||
match self.try_fips_get().await? {
|
match self.try_fips_get().await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
telemetry::record_fips_ok();
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
|||||||
} else {
|
} else {
|
||||||
budget
|
budget
|
||||||
};
|
};
|
||||||
let c = client_with_timeout(per_attempt);
|
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||||
let mut rb = c.post(&url).json(body);
|
let mut rb = c.post(&url).json(body);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
let single = self.single_delivery;
|
||||||
|
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||||
|
match tokio::time::timeout(budget, attempt).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(Ok(r)) => Ok(Some(r)),
|
||||||
|
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||||
|
"FIPS POST failed after possible delivery; not replaying: {e}"
|
||||||
|
)),
|
||||||
|
Err(_) if single => Err(anyhow::anyhow!(
|
||||||
|
"FIPS POST exceeded its budget after possible delivery; not replaying"
|
||||||
|
)),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
|||||||
} else {
|
} else {
|
||||||
budget
|
budget
|
||||||
};
|
};
|
||||||
let c = client_with_timeout(per_attempt);
|
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||||
let mut rb = c.get(&url);
|
let mut rb = c.get(&url);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
let single = self.single_delivery;
|
||||||
|
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||||
|
match tokio::time::timeout(budget, attempt).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
Ok(Ok(r)) => Ok(Some(r)),
|
||||||
|
// Anything but a failed connect may have reached the peer.
|
||||||
|
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||||
|
"FIPS GET {} failed after the request may have been delivered \
|
||||||
|
(not retrying over Tor): {}",
|
||||||
|
self.path,
|
||||||
|
e
|
||||||
|
)),
|
||||||
|
Err(_) if single => Err(anyhow::anyhow!(
|
||||||
|
"FIPS GET {} exceeded its {:?} budget after the request may have \
|
||||||
|
been delivered (not retrying over Tor)",
|
||||||
|
self.path,
|
||||||
|
budget
|
||||||
|
)),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
.context("Invalid Tor SOCKS proxy URL")?;
|
.context("Invalid Tor SOCKS proxy URL")?;
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.proxy(proxy)
|
.proxy(proxy)
|
||||||
|
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||||
.timeout(self.timeout)
|
.timeout(self.timeout)
|
||||||
.build()
|
.build()
|
||||||
.context("Build Tor HTTP client")
|
.context("Build Tor HTTP client")
|
||||||
@@ -759,4 +843,181 @@ mod tests {
|
|||||||
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
||||||
assert!(err.to_string().contains("no AAAA"));
|
assert!(err.to_string().contains("no AAAA"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_delivery_answer_is_final_whatever_its_status() {
|
||||||
|
use crate::settings::transport::TransportPref;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
// Regression (2026-09-29): the seller redeemed a paid download's
|
||||||
|
// token, answered 404, and the Tor fallback replayed the spent token.
|
||||||
|
for status in [
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
StatusCode::OK,
|
||||||
|
] {
|
||||||
|
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
|
||||||
|
}
|
||||||
|
// Everything else keeps the existing fallback rules.
|
||||||
|
assert!(!fips_answer_is_final(
|
||||||
|
TransportPref::Auto,
|
||||||
|
false,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
));
|
||||||
|
assert!(!fips_answer_is_final(
|
||||||
|
TransportPref::Auto,
|
||||||
|
false,
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
));
|
||||||
|
assert!(fips_answer_is_final(
|
||||||
|
TransportPref::Auto,
|
||||||
|
false,
|
||||||
|
StatusCode::PAYMENT_REQUIRED
|
||||||
|
));
|
||||||
|
assert!(fips_answer_is_final(
|
||||||
|
TransportPref::Fips,
|
||||||
|
false,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listener that accepts connections and never answers, counting them.
|
||||||
|
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let counter = seen.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
while let Ok((stream, _)) = listener.accept().await {
|
||||||
|
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
held.push(stream); // keep it open, never reply
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(format!("http://{addr}/content/x"), seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
|
||||||
|
let (url, seen) = silent_peer().await;
|
||||||
|
let c = client_with_timeout(Duration::from_millis(300));
|
||||||
|
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
|
||||||
|
.await
|
||||||
|
.expect_err("peer never answers");
|
||||||
|
assert!(err.is_timeout());
|
||||||
|
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
|
||||||
|
let (url, seen) = silent_peer().await;
|
||||||
|
let c = client_with_timeout(Duration::from_millis(300));
|
||||||
|
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
|
||||||
|
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_single_delivery_request_still_retries_a_refused_connect() {
|
||||||
|
// Nothing listening: the peer provably never saw the request.
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
drop(listener);
|
||||||
|
let c = client_with_timeout(Duration::from_millis(500));
|
||||||
|
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
|
||||||
|
fips_retryable(true, e)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("nothing listening");
|
||||||
|
assert!(err.is_connect());
|
||||||
|
assert!(fips_retryable(true, &err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod delivery_redirect_tests {
|
||||||
|
use super::*;
|
||||||
|
use hyper::{
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
Body, Response, Server,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
|
||||||
|
let seen = Arc::new(AtomicUsize::new(0));
|
||||||
|
let counter = seen.clone();
|
||||||
|
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||||
|
let address = server.local_addr();
|
||||||
|
let service = make_service_fn(move |_| {
|
||||||
|
let counter = counter.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||||
|
let counter = counter.clone();
|
||||||
|
async move {
|
||||||
|
counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let response = if request.uri().path() == "/first" {
|
||||||
|
Response::builder()
|
||||||
|
.status(302)
|
||||||
|
.header("Location", "/replay")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
Response::new(Body::from("replayed"))
|
||||||
|
};
|
||||||
|
Ok::<_, Infallible>(response)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(server.serve(service));
|
||||||
|
let url = format!("http://{address}/first");
|
||||||
|
let response = client_with_delivery_policy(Duration::from_secs(2), true)
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Payment-Token", "dummy-test-token")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
|
||||||
|
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||||
|
let response = client_with_delivery_policy(Duration::from_secs(2), false)
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(seen.load(Ordering::SeqCst), 3);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let seen = Arc::new(AtomicUsize::new(0));
|
||||||
|
let counter = seen.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
while let Ok((mut stream, _)) = listener.accept().await {
|
||||||
|
let mut buf = [0; 4096];
|
||||||
|
let _ = stream.read(&mut buf).await;
|
||||||
|
counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
drop(stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let c = client_with_delivery_policy(Duration::from_secs(2), true);
|
||||||
|
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
|
||||||
|
fips_retryable(true, e)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(!error.is_connect());
|
||||||
|
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
|
|||||||
| "indeedhub-api" => StartupTier::DependentService,
|
| "indeedhub-api" => StartupTier::DependentService,
|
||||||
|
|
||||||
// Tier 4: Frontend/UI
|
// Tier 4: Frontend/UI
|
||||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
|
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
|
||||||
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||||
|
|
||||||
// Tier 3: Application layer (everything else)
|
// Tier 3: Application layer (everything else)
|
||||||
_ => StartupTier::Application,
|
_ => StartupTier::Application,
|
||||||
|
|||||||
@@ -413,6 +413,11 @@ async fn main() -> Result<()> {
|
|||||||
// delays server readiness; best-effort, warnings only.
|
// delays server readiness; best-effort, warnings only.
|
||||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
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
|
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||||
// only — effective next reboot; never restarts the running service.
|
// only — effective next reboot; never restarts the running service.
|
||||||
|
|||||||
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
|
|||||||
};
|
};
|
||||||
|
|
||||||
crate::data_model::PackageDataEntry {
|
crate::data_model::PackageDataEntry {
|
||||||
state,
|
state: state.clone(),
|
||||||
// install_progress and uninstall_stage are also owned by the
|
// install_progress and uninstall_stage are also owned by the
|
||||||
// initiating op (same reason as state) — keep them.
|
// initiating op (same reason as state) — keep them.
|
||||||
install_progress: existing.install_progress.clone(),
|
install_progress: existing.install_progress.clone(),
|
||||||
uninstall_stage: existing.uninstall_stage.clone(),
|
uninstall_stage: existing.uninstall_stage.clone(),
|
||||||
// Everything else comes from the fresh scan.
|
// Everything else comes from the fresh scan.
|
||||||
|
ui_ready: if state == crate::data_model::PackageState::Running {
|
||||||
|
fresh.ui_ready
|
||||||
|
} else {
|
||||||
|
Some(false)
|
||||||
|
},
|
||||||
health: fresh.health.clone(),
|
health: fresh.health.clone(),
|
||||||
exit_code: fresh.exit_code,
|
exit_code: fresh.exit_code,
|
||||||
static_files: fresh.static_files.clone(),
|
static_files: fresh.static_files.clone(),
|
||||||
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
|
|||||||
absence_tracker: &mut HashMap<String, u32>,
|
absence_tracker: &mut HashMap<String, u32>,
|
||||||
transitional_since: &mut HashMap<String, Instant>,
|
transitional_since: &mut HashMap<String, Instant>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut packages = scanner.scan_containers().await?;
|
let (before_scan, _) = state.get_snapshot().await;
|
||||||
|
let mut packages = scanner
|
||||||
|
.scan_containers(data_dir, &before_scan.package_data)
|
||||||
|
.await?;
|
||||||
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
||||||
for (id, pkg) in packages.iter_mut() {
|
for (id, pkg) in packages.iter_mut() {
|
||||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||||
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
|
|||||||
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
||||||
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
||||||
if tor_changed || update_changed {
|
if tor_changed || update_changed {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
.mutate_data(|data| {
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
data.server_info.status_info.updated = update_available;
|
data.server_info.node_address =
|
||||||
state.update_data(data).await;
|
tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
|
data.server_info.status_info.updated = update_available;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
|
|||||||
// died without cleanup and let the scan override it.
|
// died without cleanup and let the scan override it.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for (id, pkg) in &packages {
|
for (id, pkg) in &packages {
|
||||||
|
if user_uninstalled.contains(id)
|
||||||
|
|| user_uninstalled.contains(&format!("archy-{id}"))
|
||||||
|
|| (before_scan.package_data.contains_key(id)
|
||||||
|
&& !current_data.package_data.contains_key(id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
absence_tracker.remove(id);
|
absence_tracker.remove(id);
|
||||||
let existing = merged.get(id);
|
let existing = merged.get(id);
|
||||||
let overwrite = match existing {
|
let overwrite = match existing {
|
||||||
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if changed || tor_changed || first_scan || update_changed {
|
if changed || tor_changed || first_scan || update_changed {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.package_data = merged;
|
.mutate_data(|data| {
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
// A lifecycle operation may have started/finished while this scan
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
// awaited probes or disk I/O. Never overwrite that newer entry or
|
||||||
data.server_info.status_info.containers_scanned = true;
|
// resurrect one that an uninstall removed in the meantime.
|
||||||
data.server_info.status_info.updated = update_available;
|
apply_scanned_packages(&mut data.package_data, ¤t_data.package_data, &merged);
|
||||||
state.update_data(data).await;
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
debug!(
|
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
|
data.server_info.status_info.containers_scanned = true;
|
||||||
changed, tor_changed, first_scan, update_changed
|
data.server_info.status_info.updated = update_available;
|
||||||
);
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_scanned_packages(
|
||||||
|
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
base: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
) {
|
||||||
|
for (id, fresh) in scanned {
|
||||||
|
if latest.get(id) == base.get(id) {
|
||||||
|
latest.insert(id.clone(), fresh.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in base.keys() {
|
||||||
|
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
|
||||||
|
latest.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn normalize_reachable_package_health(
|
async fn normalize_reachable_package_health(
|
||||||
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
) {
|
) {
|
||||||
@@ -2268,6 +2304,7 @@ mod merge_tests {
|
|||||||
|
|
||||||
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state,
|
state,
|
||||||
health: health.map(|s| s.to_string()),
|
health: health.map(|s| s.to_string()),
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
@@ -2280,6 +2317,37 @@ mod merge_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
|
||||||
|
let running = make_entry(PackageState::Running, Some("healthy"));
|
||||||
|
let restarting = make_entry(PackageState::Restarting, None);
|
||||||
|
let base = [
|
||||||
|
("restart".into(), running.clone()),
|
||||||
|
("uninstalled".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let mut latest = [
|
||||||
|
("restart".into(), restarting.clone()),
|
||||||
|
("new".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let scanned = [
|
||||||
|
("restart".into(), running.clone()),
|
||||||
|
("uninstalled".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
apply_scanned_packages(&mut latest, &base, &scanned);
|
||||||
|
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||||
|
assert_eq!(latest.get("new"), Some(&running));
|
||||||
|
assert!(!latest.contains_key("uninstalled"));
|
||||||
|
apply_scanned_packages(&mut latest, &base, &HashMap::new());
|
||||||
|
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||||
|
assert!(latest.contains_key("new"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||||
|
|||||||
@@ -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 ai_permissions;
|
||||||
pub mod session_policy;
|
pub mod session_policy;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
|
pub mod bitcoin_storage;
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ impl StateManager {
|
|||||||
let _ = self.broadcast_tx.send(message);
|
let _ = self.broadcast_tx.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a small state change while holding the write lock. A lifecycle
|
||||||
|
/// task must not replace the entire model from an earlier snapshot.
|
||||||
|
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
let result = change(&mut data);
|
||||||
|
let mut rev = self.revision.write().await;
|
||||||
|
*rev += 1;
|
||||||
|
let _ = self.broadcast_tx.send(WebSocketMessage {
|
||||||
|
rev: *rev,
|
||||||
|
data: Some(data.clone()),
|
||||||
|
patch: None,
|
||||||
|
});
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a WebSocket message with the current state
|
/// Get a WebSocket message with the current state
|
||||||
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
||||||
let (data, rev) = self.get_snapshot().await;
|
let (data, rev) = self.get_snapshot().await;
|
||||||
@@ -190,3 +205,29 @@ mod tests {
|
|||||||
assert_eq!(rev, 1);
|
assert_eq!(rev, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod atomic_mutation_tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_updates_preserve_independent_entries() {
|
||||||
|
let state = Arc::new(StateManager::new());
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for i in 0..24 {
|
||||||
|
let state = state.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
state
|
||||||
|
.mutate_data(|data| {
|
||||||
|
data.peer_health.insert(format!("peer-{i}"), true);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
let (data, revision) = state.get_snapshot().await;
|
||||||
|
assert_eq!(data.peer_health.len(), 24);
|
||||||
|
assert_eq!(revision, 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,46 @@ use crate::wallet::ecash;
|
|||||||
///
|
///
|
||||||
/// Returns the total sats swept in (0 if there was nothing to do, including
|
/// 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).
|
/// 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> {
|
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
|
||||||
let cfg = net_router::load_router_config(data_dir).await?;
|
let cfg = net_router::load_router_config(data_dir).await?;
|
||||||
if !cfg.configured {
|
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
|
/// service unit that inherits systemd's default protections (i.e. none
|
||||||
/// of ours), escaping the namespace.
|
/// of ours), escaping the namespace.
|
||||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
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![
|
let mut full: Vec<&str> = vec![
|
||||||
"systemd-run",
|
"systemd-run",
|
||||||
"--wait",
|
"--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
|
/// 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.
|
/// (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> {
|
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![
|
let mut full: Vec<&str> = vec![
|
||||||
"systemd-run",
|
"systemd-run",
|
||||||
"--wait",
|
"--wait",
|
||||||
@@ -2159,20 +2189,25 @@ async fn apply_per_app_auto_updates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After a catalog refresh that changed the cached bytes, rebuild the
|
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
/// may have been written before a previous reload failed. Auto-updates only run
|
||||||
/// effect now instead of at the next service restart.
|
/// when the catalog and the orchestrator's manifests are ready together.
|
||||||
async fn reload_manifests_if_changed(
|
async fn reload_catalog_manifests(
|
||||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||||
) {
|
) -> bool {
|
||||||
if !refresh.changed {
|
let Some(orch) = orchestrator else {
|
||||||
return;
|
return false;
|
||||||
}
|
};
|
||||||
let Some(orch) = orchestrator else { return };
|
|
||||||
match orch.reload_manifests().await {
|
match orch.reload_manifests().await {
|
||||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
Ok(n) => {
|
||||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
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"
|
// Refresh the app catalog once at startup so per-app "update available"
|
||||||
// badges appear without waiting for the first hourly tick.
|
// badges appear without waiting for the first hourly tick.
|
||||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
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!(
|
Err(e) => debug!(
|
||||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||||
e
|
e
|
||||||
@@ -2204,14 +2241,22 @@ pub async fn run_update_scheduler(
|
|||||||
// previously cached catalog stays in place (origin-always-wins).
|
// previously cached catalog stays in place (origin-always-wins).
|
||||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||||
// catalog-shipped manifest fixes apply without a service restart.
|
// catalog-shipped manifest fixes apply without a service restart.
|
||||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
let catalog_ready = 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: app-catalog refresh failed: {}", e),
|
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
|
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
// may still show cached badges but must not trigger container changes.
|
||||||
apply_per_app_auto_updates(&orchestrator).await;
|
if catalog_ready {
|
||||||
|
apply_per_app_auto_updates(&orchestrator).await;
|
||||||
|
}
|
||||||
|
|
||||||
let state = match load_state(&data_dir).await {
|
let state = match load_state(&data_dir).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
|
|||||||
@@ -207,7 +207,15 @@ impl CashuToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
/// 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> {
|
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) {
|
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||||
return Self::deserialize_v4(payload);
|
return Self::deserialize_v4(payload);
|
||||||
}
|
}
|
||||||
@@ -508,6 +516,45 @@ mod tests {
|
|||||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
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]
|
#[test]
|
||||||
fn test_total_amount_multi_proof() {
|
fn test_total_amount_multi_proof() {
|
||||||
let token = CashuToken {
|
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();
|
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||||
all_target.extend(&change_denoms);
|
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
|
// Mark original proofs as spent
|
||||||
wallet.mark_spent(&indices);
|
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
|
// Verify all mints in the token are accepted
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
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);
|
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
|
// for the log. Remember the last one so a total failure can tell the user
|
||||||
// *why* instead of just "nothing was received".
|
// *why* instead of just "nothing was received".
|
||||||
let mut last_reason: Option<String> = None;
|
let mut last_reason: Option<String> = None;
|
||||||
|
let mut all_already_redeemed = true;
|
||||||
|
|
||||||
// Swap proofs at each mint
|
// Swap proofs at each mint
|
||||||
for entry in &token.token {
|
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;
|
received_total += amount;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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());
|
last_reason = Some(e.to_string());
|
||||||
// Continue with other mints if any
|
// 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 {
|
if received_total == 0 {
|
||||||
match last_reason {
|
return Err(receive_failure(last_reason, all_already_redeemed));
|
||||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
|
||||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet.record_tx(
|
wallet.record_tx(
|
||||||
@@ -1243,6 +1248,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
|||||||
Ok(received_total)
|
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}).
|
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
|
||||||
/// For backwards compatibility during migration period.
|
/// For backwards compatibility during migration period.
|
||||||
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
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,
|
token_str: &str,
|
||||||
required_sats: u64,
|
required_sats: u64,
|
||||||
) -> Result<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_") {
|
if token_str.starts_with("cashuSend_") {
|
||||||
let amount = token_str
|
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
// 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)
|
// Parse and validate the token (cashuA or cashuB)
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
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 {
|
if total < required_sats {
|
||||||
anyhow::bail!(
|
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||||
"Insufficient payment: {} sats, need {} sats",
|
|
||||||
total,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mints are accepted
|
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
if !accepted
|
||||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
.mints
|
||||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
.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 wallet = load_wallet(data_dir).await?;
|
||||||
let mut received_total = 0u64;
|
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||||
|
|
||||||
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.record_tx(
|
wallet.record_tx(
|
||||||
TransactionType::Receive,
|
TransactionType::Receive,
|
||||||
@@ -1632,6 +1629,18 @@ fn default_mint_url() -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -2443,3 +2452,7 @@ mod tests {
|
|||||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
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
|
/// 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
|
/// 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`.
|
/// 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> {
|
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||||
Some(match code {
|
Some(match code {
|
||||||
10001 => "The mint rejected these coins as invalid.",
|
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.",
|
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.",
|
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.",
|
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
|
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||||
let friendly = describe_mint_error_body(status, body);
|
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
|
||||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
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.
|
/// HTTP client for a single Cashu mint.
|
||||||
@@ -487,6 +526,21 @@ impl MintClient {
|
|||||||
/// Swap proofs for new proofs of different denominations.
|
/// Swap proofs for new proofs of different denominations.
|
||||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
/// 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> {
|
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?;
|
let keyset = self.get_active_sat_keyset().await?;
|
||||||
|
|
||||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
// 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
|
// should equal outputs less fee`). Applied here rather than at each
|
||||||
// call site so send, receive and cross-mint swaps are all covered.
|
// call site so send, receive and cross-mint swaps are all covered.
|
||||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||||
let fee = match self.get_keysets().await {
|
let inputs_total = inputs
|
||||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
.iter()
|
||||||
Err(e) => {
|
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
.context("Input amount overflow")?;
|
||||||
0
|
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 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 owned_targets: Vec<u64>;
|
||||||
let target_amounts: &[u64] = if requested > spendable {
|
let target_amounts: &[u64] = if requested > spendable {
|
||||||
if spendable == 0 {
|
if spendable == 0 {
|
||||||
@@ -514,7 +587,10 @@ impl MintClient {
|
|||||||
debug!(
|
debug!(
|
||||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
"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
|
&owned_targets
|
||||||
} else {
|
} else {
|
||||||
target_amounts
|
target_amounts
|
||||||
@@ -559,6 +635,9 @@ impl MintClient {
|
|||||||
|
|
||||||
let mut new_proofs = Vec::new();
|
let mut new_proofs = Vec::new();
|
||||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
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 c_prime = sig.c_prime_as_pubkey()?;
|
||||||
let mint_key = keyset.key_for_amount(*amount)?;
|
let mint_key = keyset.key_for_amount(*amount)?;
|
||||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
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.
|
/// 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
|
/// 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
|
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
/// 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
|
/// bytes, and rejects the swap — reported as
|
||||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||||
/// a Minibits-issued token, 2026-08-17).
|
/// a Minibits-issued token, 2026-08-17).
|
||||||
///
|
///
|
||||||
/// The id only names which keyset signed the proof, so restoring the full
|
/// 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
|
/// 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
|
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||||
/// verification at the mint and no coins move. Anything already valid, or
|
/// or ambiguous short IDs are rejected before redemption.
|
||||||
/// with no unambiguous match, is passed through untouched so the mint's
|
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||||
/// own error is what the operator sees.
|
|
||||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
|
||||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||||
if !needs_repair {
|
if !needs_repair {
|
||||||
return proofs.to_vec();
|
return Ok(proofs.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mint's own keyset list, in the reference implementation's shape
|
// The mint's own keyset list, in the reference implementation's shape
|
||||||
// so its NUT-02 resolver can consume it directly.
|
// so its NUT-02 resolver can consume it directly.
|
||||||
let known = match self.get_cdk_keysets().await {
|
let known = 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proofs
|
proofs
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|mut p| {
|
.map(|mut p| {
|
||||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
if is_truncated_v2_keyset_id(&p.id) {
|
||||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||||
p.id = full;
|
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||||
}
|
}
|
||||||
p
|
Ok(p)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -777,7 +848,7 @@ impl MintClient {
|
|||||||
let mut all_new_proofs = Vec::new();
|
let mut all_new_proofs = Vec::new();
|
||||||
|
|
||||||
for entry in &token.token {
|
for entry in &token.token {
|
||||||
if entry.mint != self.url {
|
if entry.mint.trim_end_matches('/') != self.url {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping proofs from different mint {} (ours: {})",
|
"Skipping proofs from different mint {} (ours: {})",
|
||||||
entry.mint, self.url
|
entry.mint, self.url
|
||||||
@@ -788,8 +859,7 @@ impl MintClient {
|
|||||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(total);
|
let target_amounts = amount_to_denominations(total);
|
||||||
|
|
||||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||||
let result = self.swap(&proofs, &target_amounts).await?;
|
|
||||||
all_new_proofs.extend(result.new_proofs);
|
all_new_proofs.extend(result.new_proofs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +873,28 @@ impl MintClient {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod bdhke;
|
|||||||
pub mod cashu;
|
pub mod cashu;
|
||||||
pub mod ecash;
|
pub mod ecash;
|
||||||
pub mod fedimint_client;
|
pub mod fedimint_client;
|
||||||
|
pub mod minibits;
|
||||||
pub mod mint_client;
|
pub mod mint_client;
|
||||||
pub mod nut13;
|
pub mod nut13;
|
||||||
pub mod profits;
|
pub mod profits;
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ impl EcashSeed {
|
|||||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
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 {
|
pub fn source(&self) -> SeedSource {
|
||||||
self.source
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -989,6 +989,24 @@ impl AppManifest {
|
|||||||
validate_security(&self.app.security)?;
|
validate_security(&self.app.security)?;
|
||||||
validate_ports(&self.app.ports)?;
|
validate_ports(&self.app.ports)?;
|
||||||
validate_interfaces(&self.app.interfaces)?;
|
validate_interfaces(&self.app.interfaces)?;
|
||||||
|
if let Some(value) = self.app.extensions.get("install_prerequisites") {
|
||||||
|
let items = value.as_sequence().ok_or_else(|| {
|
||||||
|
ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
|
||||||
|
})?;
|
||||||
|
for item in items {
|
||||||
|
let id = item.as_str().unwrap_or_default();
|
||||||
|
if id.is_empty()
|
||||||
|
|| id == self.app.id
|
||||||
|
|| !id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
{
|
||||||
|
return Err(ManifestError::Invalid(
|
||||||
|
"install_prerequisites must contain valid other app ids".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
validate_environment(&self.app.environment)?;
|
validate_environment(&self.app.environment)?;
|
||||||
validate_devices(&self.app.devices)?;
|
validate_devices(&self.app.devices)?;
|
||||||
|
|
||||||
@@ -1074,6 +1092,14 @@ impl AppManifest {
|
|||||||
// `..` copy sources). See docs/manifest-hooks-design.md.
|
// `..` copy sources). See docs/manifest-hooks-design.md.
|
||||||
self.app.hooks.validate()?;
|
self.app.hooks.validate()?;
|
||||||
|
|
||||||
|
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
|
||||||
|
if value.as_bool().is_none() {
|
||||||
|
return Err(ManifestError::Invalid(
|
||||||
|
"backup_before_runtime_change must be boolean".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1111,6 +1137,7 @@ fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
|
|||||||
"SETGID",
|
"SETGID",
|
||||||
"SETUID",
|
"SETUID",
|
||||||
"SYS_ADMIN",
|
"SYS_ADMIN",
|
||||||
|
"SYS_CHROOT",
|
||||||
];
|
];
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for cap in &policy.capabilities {
|
for cap in &policy.capabilities {
|
||||||
@@ -1746,45 +1773,48 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exempt.sort();
|
exempt.sort();
|
||||||
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
|
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
|
||||||
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
|
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
||||||
// same reason router's mDNS/SSDP and every p2p port is exempt; each
|
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||||
// carries its auth_rationale in the manifest.
|
let expected = [
|
||||||
//
|
("bitcoin-core", 8333),
|
||||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
("bitcoin-knots", 8333),
|
||||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
("core-lightning", 9736),
|
||||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
("core-lightning", 9835),
|
||||||
// upstream's own safe-for-public
|
("cuprate", 18183),
|
||||||
// subset that wallets connect to directly as a "remote node" over
|
("electrumx", 50001),
|
||||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
("fedimint", 8173),
|
||||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
("fedimint", 8174),
|
||||||
// (auth: local), not in this set.
|
("fedimint-gateway", 8176),
|
||||||
//
|
("fedimint-gateway", 9737),
|
||||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
("gitea", 2222),
|
||||||
// loopback-only JSON API whose own generated http password
|
("lnd", 9735),
|
||||||
// authenticates every request (added with the phoenixd onboarding,
|
("lnd", 10009),
|
||||||
// which did not update this count — exactly the drift this test
|
("lnd", 18080),
|
||||||
// exists to catch).
|
("netbird", 8087),
|
||||||
//
|
("netbird-server", 3478),
|
||||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
("netbird-server", 8086),
|
||||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
("phoenixd", 9740),
|
||||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
("pine", 10381),
|
||||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
("pine-openwakeword", 10400),
|
||||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
("pine-piper", 10200),
|
||||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
("pine-whisper", 10300),
|
||||||
// deliberate, rationale-carrying exemption; the release-gate test
|
("router", 1900),
|
||||||
// stage timed out that cycle, so the count here lagged at 17.
|
("router", 5353),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, port)| (id.to_owned(), port))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
exempt.len(),
|
exempt, expected,
|
||||||
30,
|
"unauthenticated endpoint set changed; review each exemption"
|
||||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||||
/// same review guard as `auth: none`. Each one must be an app that
|
/// same review guard as `auth: none`. Each must enforce its own login or
|
||||||
/// enforces a real login of its own.
|
/// have an explicitly reviewed public protocol purpose.
|
||||||
#[test]
|
#[test]
|
||||||
fn gate_open_ports_are_all_accounted_for() {
|
fn gate_open_ports_are_all_accounted_for() {
|
||||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||||
@@ -1806,27 +1836,44 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
open.sort();
|
open.sort();
|
||||||
|
// Cuprate 18090 is its deliberately public restricted RPC subset;
|
||||||
|
// unrestricted node-control RPC remains container-loopback-only.
|
||||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
||||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||||
// three own-login consoles brought onto the manifest platform:
|
// three own-login consoles brought onto the manifest platform:
|
||||||
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
|
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
|
||||||
// (tailnet login on the web console), adguardhome 3000 (AGH admin
|
// (tailnet login on the web console). Both enforce their own login,
|
||||||
// accounts + first-run wizard). All enforce their own login, and an
|
// and an operator can re-gate either from Settings → Access control.
|
||||||
// operator can re-gate any of them from Settings → Access control.
|
// Angor's indexer exposes public chain data/transaction broadcast;
|
||||||
|
// its optional standalone relay accepts signed public Nostr events.
|
||||||
|
// Neither mounts credentials or the node's internal relay database.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
open,
|
open,
|
||||||
vec![
|
vec![
|
||||||
("adguardhome".to_string(), 3000u16),
|
("angor-indexer".to_string(), 8998u16),
|
||||||
|
("angor-relay".to_string(), 8091u16),
|
||||||
("btcpay-server".to_string(), 23000u16),
|
("btcpay-server".to_string(), 23000u16),
|
||||||
|
("cuprate".to_string(), 18090u16),
|
||||||
("gitea".to_string(), 3001u16),
|
("gitea".to_string(), 3001u16),
|
||||||
("nginx-proxy-manager".to_string(), 8081u16),
|
("nginx-proxy-manager".to_string(), 8081u16),
|
||||||
("tailscale".to_string(), 8240u16),
|
("tailscale".to_string(), 8240u16),
|
||||||
],
|
],
|
||||||
"gate-open port set changed — every entry must be an app with its own login"
|
"gate-open port set changed — review login or intentional public protocol purpose"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_install_prerequisites_are_rejected() {
|
||||||
|
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
|
||||||
|
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
|
||||||
|
assert!(AppManifest::parse(&yaml)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("install_prerequisites"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
||||||
// Two different questions, and conflating them caused both gate
|
// Two different questions, and conflating them caused both gate
|
||||||
|
|||||||
@@ -310,59 +310,7 @@ impl PodmanClient {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
port_mappings.push(podman_publish_mapping(port));
|
||||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
|
||||||
// silently break relay discovery.
|
|
||||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
|
||||||
"udp" => "udp",
|
|
||||||
"sctp" => "sctp",
|
|
||||||
_ => "tcp",
|
|
||||||
};
|
|
||||||
// Effective bind. A gated port with no declared bind would
|
|
||||||
// publish 0.0.0.0 — the app would own every host address, which
|
|
||||||
// is both the exposure itself and the reason the daemon's app
|
|
||||||
// gate cannot bind those addresses to authenticate them. Pin it
|
|
||||||
// to loopback so the gate can take the external addresses.
|
|
||||||
//
|
|
||||||
// Doing it HERE, at container creation, is the point: the pin and
|
|
||||||
// the gate's takeover then both come from the daemon and cannot
|
|
||||||
// disagree. The earlier attempt put this decision in manifest
|
|
||||||
// data instead, and a node whose manifests lagged the binary
|
|
||||||
// published Bitcoin's loopback-only RPC across the LAN
|
|
||||||
// (test node, 2026-08-03).
|
|
||||||
//
|
|
||||||
// A port that already declares a bind is never overridden — that
|
|
||||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
|
||||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
|
||||||
// published as they are, so remote wallets keep working.
|
|
||||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
|
||||||
// published port to loopback is how an app hands its external
|
|
||||||
// addresses to the gate, but it belongs in the manifest, not in
|
|
||||||
// daemon-side inference:
|
|
||||||
//
|
|
||||||
// * `bind` is already honoured by every publish path (here and
|
|
||||||
// in package::install), so a manifest edit needs no code.
|
|
||||||
// * inference here would cover only THIS path — proven on
|
|
||||||
// a test node, where a recreate went through another one and
|
|
||||||
// the pin never applied.
|
|
||||||
// * and inferring from an ABSENT field is what republished
|
|
||||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
|
||||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
|
||||||
// every remote wallet.
|
|
||||||
//
|
|
||||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
|
||||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
|
||||||
// overridden by the catalog, which is precisely why the catalog is
|
|
||||||
// the right and only place to carry it.
|
|
||||||
let mut mapping = serde_json::json!({
|
|
||||||
"container_port": port.container,
|
|
||||||
"host_port": port.host,
|
|
||||||
"protocol": protocol,
|
|
||||||
});
|
|
||||||
if !port.bind.is_empty() {
|
|
||||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
|
||||||
}
|
|
||||||
port_mappings.push(mapping);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = Vec::new();
|
let mut mounts = Vec::new();
|
||||||
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
|
|||||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the explicitly declared bind and transport identical to Quadlet. The
|
||||||
|
// app gate owns external listeners; container publication must not bypass it.
|
||||||
|
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
|
||||||
|
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||||
|
"udp" => "udp",
|
||||||
|
"sctp" => "sctp",
|
||||||
|
_ => "tcp",
|
||||||
|
};
|
||||||
|
let mut mapping = serde_json::json!({
|
||||||
|
"container_port": port.container,
|
||||||
|
"host_port": port.host,
|
||||||
|
"protocol": protocol,
|
||||||
|
});
|
||||||
|
if !port.bind.is_empty() {
|
||||||
|
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||||
|
}
|
||||||
|
mapping
|
||||||
|
}
|
||||||
|
|
||||||
fn podman_network_settings(
|
fn podman_network_settings(
|
||||||
network: Option<&str>,
|
network: Option<&str>,
|
||||||
network_policy: &str,
|
network_policy: &str,
|
||||||
@@ -1110,6 +1077,24 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||||
|
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
podman_network_settings(
|
||||||
|
m.app.container.network.as_deref(),
|
||||||
|
&m.app.security.network_policy
|
||||||
|
),
|
||||||
|
("slirp4netns", None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
podman_publish_mapping(&m.app.ports[0]),
|
||||||
|
serde_json::json!({
|
||||||
|
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user