Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7abd04a7f6 | ||
|
|
441733646f | ||
|
|
ccf823590a | ||
|
|
d9775ac144 | ||
|
|
0925c58821 | ||
|
|
2d27f9c475 | ||
|
|
868e46fac9 | ||
|
|
2aa77d1b7b | ||
|
|
a6b9e7ab49 | ||
|
|
a902cc84fe | ||
|
|
157c9ec055 | ||
|
|
415826f0a6 | ||
|
|
69857c4ace | ||
|
|
e6e46a1427 | ||
|
|
e0b2181ae9 | ||
|
|
446fa7b7fd | ||
|
|
ba8b1f29b2 | ||
|
|
b8266c2872 | ||
|
|
5aa74d0513 | ||
|
|
daac47cac4 | ||
|
|
138a541d01 | ||
|
|
833c939220 | ||
|
|
2c1bcacf0a | ||
|
|
f1d0092e57 | ||
|
|
7dfb0e0013 | ||
|
|
775d7b9877 | ||
|
|
c18ebd7f5b | ||
|
|
494d248356 | ||
|
|
3acefecc24 | ||
|
|
19c49c6605 | ||
|
|
d6e0c142c6 | ||
|
|
57729f8e18 | ||
|
|
4fdadad89d | ||
|
|
f4d3455496 | ||
|
|
227174e541 | ||
|
|
2e72b38778 | ||
|
|
0be7aee49d | ||
|
|
6d5f3ffb85 | ||
|
|
d1bc1273d4 | ||
|
|
96fb5a4f19 | ||
|
|
f91c1f33db | ||
|
|
02b840f2d1 | ||
|
|
f992780957 | ||
|
|
c82c1eee98 | ||
|
|
2992443d5d | ||
|
|
259c353147 | ||
|
|
1724ea05d1 | ||
|
|
c1e20a71ae | ||
|
|
bf56956790 | ||
|
|
2f1a3ade07 | ||
|
|
ef8254272c | ||
|
|
d50be13232 | ||
|
|
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 | ||
|
|
dc7b598558 | ||
|
|
69f3a355c7 |
+7
-1
@@ -4,7 +4,13 @@
|
||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||
!neode-ui/
|
||||
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!aiui/
|
||||
aiui/**/node_modules
|
||||
aiui/**/dist
|
||||
aiui/**/.turbo
|
||||
aiui/**/.build-aiui-last-*
|
||||
|
||||
# Allow curated demo assets
|
||||
!demo/
|
||||
|
||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
# Source pushes prepare images; public deployment is an explicit post-release action.
|
||||
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
# Source pushes prepare images; public deployment is an explicit post-release action.
|
||||
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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.
|
||||
|
||||
## Active release regression checklist
|
||||
|
||||
Before resuming release work, read
|
||||
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
|
||||
The operator requested that every reported issue be tracked, fixed and tested
|
||||
before another OTA/ISO. Keep source/unit-test results separate from actual-node
|
||||
acceptance. In particular, paid-file recovery must not send another payment,
|
||||
and app cleanup must preserve wallets, persistent data and uninstall decisions.
|
||||
Do not mark the new paid-file incident resolved merely because the earlier
|
||||
Framework LND startup incident was closed.
|
||||
|
||||
## Gitea and ngit mirror parity
|
||||
|
||||
Nostr Git (`ngit`) is the canonical contribution and review platform. Gitea
|
||||
(`origin`) mirrors accepted code on `main` and release tags. Both are required
|
||||
publication mirrors; duplicate PRs and proposal branches on Gitea are not required.
|
||||
For every change, including fixes and release preparation:
|
||||
|
||||
- Review and merge once. Push the exact same resulting commits to both mirrors;
|
||||
never independently squash, rebase or merge the same change on each platform.
|
||||
- Open new contributions and PRs on ngit; review and merge there, then mirror the
|
||||
exact accepted main commits to Gitea. Record the ngit proposal and resulting
|
||||
merge commit in the release ledger. Existing Gitea PRs must be reviewed and
|
||||
explicitly linked to their ngit replacement or accepted result before closing;
|
||||
do not abandon contributions or mark unmerged changes as merged. PR numbers,
|
||||
reviews and discussions remain platform-specific; matching Git refs does not
|
||||
prove their synchronization.
|
||||
- Push main and release tags to both mirrors. Preserve commit history
|
||||
and annotated tag objects/signatures. Do not resolve drift by force pushing,
|
||||
deleting remote refs, or rewriting published history without explicit approval.
|
||||
- After publishing source, run `python3 scripts/check-git-mirrors.py --local`.
|
||||
Include each additional shared branch or release tag with repeated `--ref`
|
||||
arguments (full `refs/heads/...` or `refs/tags/...` names).
|
||||
- Before OTA, catalog or ISO publication, require matching reviewed local and
|
||||
remote main and release tag refs, and record ngit PR dispositions in the
|
||||
release acceptance ledger. A failed push, unavailable mirror, missing ref or
|
||||
mismatch blocks publication; never describe a partial push as synchronized.
|
||||
Run `--all` for a complete advertised branch/tag audit; a main-only pass must
|
||||
never be described as full historical mirror parity. Proposal-only branches
|
||||
may intentionally differ. Existing unrelated drift
|
||||
must be inventoried explicitly rather than silently overwritten.
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 52
|
||||
versionName = "0.5.32"
|
||||
versionCode = 54
|
||||
versionName = "0.5.34"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -142,6 +142,9 @@ tasks.matching {
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.DownloadListener
|
||||
import android.webkit.URLUtil
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String)
|
||||
|
||||
/** Only the starting origin receives its WebView cookies, even across redirects. */
|
||||
internal fun streamWebDownload(
|
||||
download: WebDownload,
|
||||
output: OutputStream,
|
||||
client: OkHttpClient,
|
||||
onCall: (Call) -> Unit = {},
|
||||
checkCancelled: () -> Unit = {},
|
||||
onProgress: (Long, Long) -> Unit = { _, _ -> },
|
||||
): Long {
|
||||
val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
|
||||
val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link")
|
||||
var url = original
|
||||
var redirects = 0
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link")
|
||||
val request = Request.Builder().url(url).header("User-Agent", download.userAgent)
|
||||
if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) {
|
||||
request.header("Cookie", download.cookies)
|
||||
}
|
||||
val call = transport.newCall(request.build())
|
||||
onCall(call)
|
||||
call.execute().use { response ->
|
||||
if (response.code in listOf(301, 302, 303, 307, 308)) {
|
||||
if (++redirects > 5) throw IOException("Too many download redirects")
|
||||
val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect")
|
||||
if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked")
|
||||
url = next
|
||||
} else {
|
||||
if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download")
|
||||
if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})")
|
||||
if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" &&
|
||||
download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) {
|
||||
throw IOException("Sign in to the node again, then retry the download")
|
||||
}
|
||||
val body = response.body ?: throw IOException("The download was empty")
|
||||
val total = body.contentLength()
|
||||
var written = 0L
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var lastUpdate = 0L
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
val count = input.read(buffer)
|
||||
if (count == -1) break
|
||||
output.write(buffer, 0, count)
|
||||
written += count
|
||||
val now = System.nanoTime()
|
||||
if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now }
|
||||
}
|
||||
}
|
||||
if (total >= 0 && written != total) throw IOException("Download interrupted; please retry")
|
||||
onProgress(written, total)
|
||||
return written
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses the system Save dialog: no broad storage permission and no external browser login. */
|
||||
@Composable
|
||||
internal fun rememberWebViewDownloads(): DownloadListener {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var pending by remember { mutableStateOf<WebDownload?>(null) }
|
||||
var active by remember { mutableStateOf<WebDownload?>(null) }
|
||||
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(null) }
|
||||
val client = remember {
|
||||
OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build()
|
||||
}
|
||||
fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() }
|
||||
DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } }
|
||||
val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val download = pending
|
||||
pending = null
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult
|
||||
job = scope.launch {
|
||||
active = download
|
||||
progress = 0L to -1L
|
||||
var complete = false
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val task = currentCoroutineContext()
|
||||
context.contentResolver.openOutputStream(uri, "w")?.use { output ->
|
||||
streamWebDownload(download, output, client,
|
||||
onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() },
|
||||
checkCancelled = { task.ensureActive() },
|
||||
onProgress = { done, total -> scope.launch { progress = done to total } })
|
||||
} ?: throw IOException("Unable to open the selected destination")
|
||||
}
|
||||
complete = true
|
||||
Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (currentCoroutineContext().isActive) {
|
||||
// Do not expose authenticated URLs or request headers in UI/logs.
|
||||
failure = when {
|
||||
error is javax.net.ssl.SSLException -> "The server certificate could not be verified."
|
||||
error is IOException && error.message?.startsWith("Sign in") == true -> error.message
|
||||
else -> "Download failed. Check your connection and available storage, then try again."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentCall.getAndSet(null)?.cancel()
|
||||
if (!complete) withContext(NonCancellable + Dispatchers.IO) {
|
||||
// This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file.
|
||||
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
|
||||
}
|
||||
active = null
|
||||
job = null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (active != null) {
|
||||
AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = {
|
||||
Column {
|
||||
Text(active!!.name)
|
||||
if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f))
|
||||
else LinearProgressIndicator()
|
||||
}
|
||||
}, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } })
|
||||
}
|
||||
failure?.let { message ->
|
||||
AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") },
|
||||
text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } })
|
||||
}
|
||||
return DownloadListener { url, userAgent, disposition, mimeType, _ ->
|
||||
if (active != null || pending != null) {
|
||||
Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show()
|
||||
} else if (url.toHttpUrlOrNull() == null) {
|
||||
failure = "This download link is not supported. Open the file from Cloud and try again."
|
||||
} else {
|
||||
val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream"
|
||||
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" }
|
||||
pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime)
|
||||
try {
|
||||
save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name)
|
||||
})
|
||||
} catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
private fun Context.fullscreenActivity(): ComponentActivity? = when (this) {
|
||||
is ComponentActivity -> this
|
||||
is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */
|
||||
internal class WebViewFullscreen(private val activity: ComponentActivity?) {
|
||||
private var overlay: FrameLayout? = null
|
||||
private var callback: WebChromeClient.CustomViewCallback? = null
|
||||
private var back: OnBackPressedCallback? = null
|
||||
private var visibleBars = 0
|
||||
private var originalBehavior = 0
|
||||
|
||||
fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) {
|
||||
val owner = activity
|
||||
// A second enter must not detach the active video or strand its callback.
|
||||
if (owner == null || owner.isFinishing || owner.isDestroyed || view == null ||
|
||||
view.parent != null || overlay != null
|
||||
) {
|
||||
onHidden?.onCustomViewHidden()
|
||||
return
|
||||
}
|
||||
val decor = owner.window.decorView as? ViewGroup
|
||||
if (decor == null) { onHidden?.onCustomViewHidden(); return }
|
||||
val controller = WindowCompat.getInsetsController(owner.window, decor)
|
||||
val insets = ViewCompat.getRootWindowInsets(decor)
|
||||
visibleBars = 0
|
||||
if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) {
|
||||
visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars()
|
||||
}
|
||||
if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) {
|
||||
visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars()
|
||||
}
|
||||
originalBehavior = controller.systemBarsBehavior
|
||||
val host = FrameLayout(owner).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
keepScreenOn = true
|
||||
addView(view, FrameLayout.LayoutParams(-1, -1))
|
||||
}
|
||||
overlay = host
|
||||
callback = onHidden
|
||||
decor.addView(host, ViewGroup.LayoutParams(-1, -1))
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
back = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = hide()
|
||||
}.also { owner.onBackPressedDispatcher.addCallback(it) }
|
||||
view.requestFocus()
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val host = overlay ?: return
|
||||
// Clear first: Chromium may synchronously call onHideCustomView again.
|
||||
overlay = null
|
||||
val notify = callback
|
||||
callback = null
|
||||
back?.remove()
|
||||
back = null
|
||||
host.keepScreenOn = false
|
||||
host.removeAllViews()
|
||||
(host.parent as? ViewGroup)?.removeView(host)
|
||||
activity?.let { owner ->
|
||||
val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView)
|
||||
controller.systemBarsBehavior = originalBehavior
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
if (visibleBars != 0) controller.show(visibleBars)
|
||||
}
|
||||
notify?.onCustomViewHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberWebViewFullscreen(): WebViewFullscreen {
|
||||
val context = LocalContext.current
|
||||
val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) }
|
||||
DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } }
|
||||
return fullscreen
|
||||
}
|
||||
@@ -611,6 +611,8 @@ fun WebViewScreen(
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val downloads = rememberWebViewDownloads()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||
// loader; later navigations keep just the slim top progress bar.
|
||||
@@ -913,6 +915,7 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
setDownloadListener(downloads)
|
||||
settings.apply {
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
@@ -1181,6 +1184,12 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
|
||||
fullscreen.show(view, callback)
|
||||
}
|
||||
|
||||
override fun onHideCustomView() = fullscreen.hide()
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
loadProgress = newProgress
|
||||
}
|
||||
@@ -1546,6 +1555,8 @@ private fun InAppBrowser(
|
||||
appName: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val downloads = rememberWebViewDownloads()
|
||||
val context = LocalContext.current
|
||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||
@@ -1643,6 +1654,7 @@ private fun InAppBrowser(
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
setDownloadListener(downloads)
|
||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||
// served over plain HTTP too — same dead-clipboard trap.
|
||||
addClipboardBridge()
|
||||
@@ -1662,6 +1674,12 @@ private fun InAppBrowser(
|
||||
)
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
|
||||
fullscreen.show(view, callback)
|
||||
}
|
||||
|
||||
override fun onHideCustomView() = fullscreen.hide()
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
class WebViewDownloadsTest {
|
||||
private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream")
|
||||
@Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() }
|
||||
server.enqueue(MockResponse().setBody(Buffer().write(bytes)))
|
||||
val out = ByteArrayOutputStream()
|
||||
var progress = 0L to 0L
|
||||
assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total }))
|
||||
assertArrayEquals(bytes, out.toByteArray())
|
||||
assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress)
|
||||
assertEquals("session=test-only", server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
}
|
||||
@Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() {
|
||||
MockWebServer().use { first -> MockWebServer().use { second ->
|
||||
second.enqueue(MockResponse().setBody("final"))
|
||||
first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative"))
|
||||
first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target")))
|
||||
val out = ByteArrayOutputStream()
|
||||
streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient())
|
||||
assertEquals("final", out.toString())
|
||||
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
|
||||
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
|
||||
assertNull(second.takeRequest().getHeader("Cookie"))
|
||||
} }
|
||||
}
|
||||
@Test fun authenticationFailureDoesNotSaveErrorBody() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("login required"))
|
||||
val out = ByteArrayOutputStream()
|
||||
val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) }
|
||||
assertTrue(error.message!!.startsWith("Sign in"))
|
||||
assertEquals(0, out.size())
|
||||
}
|
||||
}
|
||||
@Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() {
|
||||
MockWebServer().use { server ->
|
||||
repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) }
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) }
|
||||
assertEquals(6, server.requestCount)
|
||||
}
|
||||
for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) {
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) }
|
||||
}
|
||||
}
|
||||
@Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody("bytes"))
|
||||
assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) }
|
||||
assertEquals(0, server.requestCount)
|
||||
var progressCalled = false
|
||||
val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } }
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) }
|
||||
assertFalse(progressCalled)
|
||||
}
|
||||
}
|
||||
@Test fun tlsDowngradeAndLoginHtmlAreRejected() {
|
||||
var requests = 0
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
requests++
|
||||
okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1)
|
||||
.code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build()
|
||||
}.build()
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) }
|
||||
assertEquals(1, requests)
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("<html>Sign in</html>"))
|
||||
val out = ByteArrayOutputStream()
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
|
||||
assertEquals(0, out.size())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||
class WebViewFullscreenTest {
|
||||
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val fullscreen = WebViewFullscreen(activity)
|
||||
val video = View(activity)
|
||||
var hidden = 0
|
||||
fullscreen.show(video) { hidden++ }
|
||||
assertNotNull(video.parent)
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
assertNull(video.parent)
|
||||
assertFalse(activity.isFinishing)
|
||||
assertEquals(1, hidden)
|
||||
fullscreen.hide()
|
||||
assertEquals(1, hidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
|
||||
@Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val fullscreen = WebViewFullscreen(activity)
|
||||
val first = View(activity)
|
||||
val second = View(activity)
|
||||
var firstHidden = 0
|
||||
var secondHidden = 0
|
||||
fullscreen.show(first) { firstHidden++ }
|
||||
fullscreen.show(second) { secondHidden++ }
|
||||
assertNotNull(first.parent)
|
||||
assertNull(second.parent)
|
||||
assertEquals(0, firstHidden)
|
||||
assertEquals(1, secondHidden)
|
||||
fullscreen.hide()
|
||||
fullscreen.show(second) { secondHidden++ }
|
||||
assertNotNull(second.parent)
|
||||
fullscreen.hide()
|
||||
assertEquals(1, firstHidden)
|
||||
assertEquals(2, secondHidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
|
||||
@Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val video = View(activity)
|
||||
val owner = FrameLayout(activity).apply { addView(video) }
|
||||
var hidden = 0
|
||||
WebViewFullscreen(activity).show(video) { hidden++ }
|
||||
assertSame(owner, video.parent)
|
||||
WebViewFullscreen(null).show(null) { hidden++ }
|
||||
assertEquals(2, hidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
}
|
||||
+111
-1
@@ -1,6 +1,116 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.9.0-alpha (2026-10-05)
|
||||
|
||||
Unpublished release candidate; qualification is still in progress.
|
||||
|
||||
- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.
|
||||
- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.
|
||||
- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.
|
||||
- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.
|
||||
- Restrict public management access while retaining configured public apps and ACME certificate validation.
|
||||
- Serve the Mempool explorer on the Angor indexer origin alongside its API.
|
||||
- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.
|
||||
- Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.
|
||||
- Return explicit payment-status errors with safe retry guidance when verification is unavailable.
|
||||
- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.
|
||||
- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.
|
||||
- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.
|
||||
- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.
|
||||
- Allow apps to opt in to a validated public-key list of user identities without granting signing access.
|
||||
- Make transaction filters transparent and horizontally scrollable on mobile.
|
||||
- Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.
|
||||
- Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
|
||||
- Offer Standard, Medium, Fast and custom fees when cooperatively closing Lightning channels.
|
||||
- Add a clear-search icon to My Apps, Services and the App Store on desktop and mobile.
|
||||
- Keep Angor Indexer and the optional Angor Relay in the signed app catalog. Full indexing requires a synced, unpruned Bitcoin node and its indexing dependencies.
|
||||
|
||||
## v1.8.22-alpha (2026-09-30)
|
||||
|
||||
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
|
||||
|
||||
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
|
||||
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
|
||||
- Fixed companion dashboard builds still referencing a retired image registry.
|
||||
|
||||
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
|
||||
|
||||
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
|
||||
- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.
|
||||
|
||||
- Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.
|
||||
- Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.
|
||||
|
||||
- Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.
|
||||
|
||||
- Named the app in compact readiness messages and kept app-card actions aligned at the bottom.
|
||||
- Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.
|
||||
|
||||
- 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)
|
||||
|
||||
|
||||
+38
-1
@@ -64,12 +64,49 @@ App submissions must:
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Open one focused PR per behavior or documentation change.
|
||||
Contributions, PRs and reviews live on **ngit**. Clone the canonical repository:
|
||||
|
||||
```text
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
```
|
||||
|
||||
Gitea is a conventional Git mirror of accepted `main` commits and release tags.
|
||||
You do not need to open a duplicate Gitea PR. Existing Gitea contributions will
|
||||
be reviewed and linked to their ngit replacement or accepted result before
|
||||
closure.
|
||||
|
||||
1. Open one focused ngit PR per behavior or documentation change.
|
||||
2. Explain what changed, why it changed, and how it was verified.
|
||||
3. Include screenshots for UI changes.
|
||||
4. Link relevant issues or docs.
|
||||
5. Keep generated catalog changes in sync with manifest changes.
|
||||
|
||||
### Maintainer publication gate
|
||||
|
||||
Merge once through the ngit contribution workflow, then push the exact same
|
||||
accepted commits to Gitea. Do not independently merge or squash on each mirror.
|
||||
Publish identical release tag objects, including annotations and signatures.
|
||||
After pushing main, verify:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local
|
||||
```
|
||||
|
||||
Before publishing release artifacts, also check the actual release tag:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha
|
||||
```
|
||||
|
||||
Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
|
||||
object IDs block publication. Record the ngit PR disposition and resulting merge
|
||||
commit in the release acceptance ledger. Resolve drift deliberately; do not
|
||||
force-push or delete published history without explicit approval.
|
||||
|
||||
The checker is read-only. `--all` audits every advertised branch and tag; ngit
|
||||
proposal branches may intentionally differ from Gitea. A main-only pass proves
|
||||
only main parity, and no Git ref check verifies PR discussions or review state.
|
||||
|
||||
Suggested commit format:
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Archipelago
|
||||
|
||||
> **Alpha testing:** Archipelago is experimental software. Any funds you put on it are at your own risk.
|
||||
|
||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||
|
||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||
|
||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Clean up stale buckets every 5 minutes
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000)
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -57,12 +57,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -70,6 +66,19 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -436,13 +436,13 @@
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"version": "2.14.0",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
@@ -644,6 +644,35 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.2",
|
||||
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. 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.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
"Unpruned Bitcoin"
|
||||
],
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"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-green.png",
|
||||
"repoUrl": "https://github.com/hoytech/strfry"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Angor Indexer
|
||||
|
||||
Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
|
||||
the same origin. The service reuses this node's Mempool frontend/backend and
|
||||
Electrum index instead of creating another explorer or 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.
|
||||
|
||||
## Verify the complete client flow
|
||||
|
||||
The root URL opens the Mempool explorer. `/health` and fee
|
||||
estimates establish API availability; they do not prove that project discovery,
|
||||
address history, or browser CORS works. Test a known funded project's address
|
||||
history, its original Nostr announcement, the Explore page, and project details
|
||||
in the actual Angor client. A certificate alone does not establish public routing.
|
||||
|
||||
Keep existing discovery relays when adding a new relay. A new relay has no
|
||||
historical project data and does not automatically replicate other relays.
|
||||
Even with existing relays, an empty Explore page can be a client discovery
|
||||
failure: Angor Hub v2.0.0 was observed to stop after a batch whose announcements
|
||||
all failed on-chain validation. The same failure reproduced with our indexer
|
||||
and Angor's public indexer. Do not bypass the funding transaction's event-ID
|
||||
commitment or substitute an unsigned announcement to make a project appear.
|
||||
|
||||
For opt-in read-only browser acceptance, install the frontend test dependencies
|
||||
and Playwright Chromium, then run:
|
||||
|
||||
```sh
|
||||
ANGOR_TEST_INDEXER=https://indexer.example.com/ \
|
||||
ANGOR_TEST_RELAY=wss://relay.example.com/ \
|
||||
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay.example.com/"]' \
|
||||
node tests/lifecycle/angor-public-browser.cjs
|
||||
```
|
||||
|
||||
The relay under test must already contain the known original public project
|
||||
announcement documented in the test. The test does not import events, send
|
||||
funds, change your browser profile, or disable TLS verification. It checks the
|
||||
funding transaction/event commitment and real browser discovery and details.
|
||||
Relay signed writes, invalid-signature rejection, persistence, full node sync,
|
||||
and proxy upgrade/renewal tests remain separate acceptance requirements.
|
||||
|
||||
## Packaging
|
||||
|
||||
Build the pinned image with:
|
||||
|
||||
```
|
||||
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.2 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 app icon is based on [Angor’s dark-mode app icon](https://angor.io/images/app-icon-dark-mode.png), retrieved 2026-09-30. At the operator’s request, the outer corners use the same green as the background. The built-in imagegen edit preserved the black mark and filled the square green; the project asset is `neode-ui/public/assets/img/app-icons/angor-green.png`.
|
||||
|
||||
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.
|
||||
|
||||
## Explorer on the public indexer origin
|
||||
|
||||
The linked official deployment guide exposes **Mempool frontend and API together**
|
||||
on the public indexer URL. It uses standard Mempool images and requires no custom
|
||||
Angor fork or `ANGOR_ENABLED` flag.
|
||||
|
||||
The operator now requires that same browser experience: opening the configured
|
||||
indexer domain must show the existing Mempool explorer, while Angor API requests
|
||||
continue working on that origin. Reuse the existing Mempool stack, including its
|
||||
live WebSocket feed; do not install a second explorer or blockchain database.
|
||||
|
||||
**Candidate 1.0.2:** `/` and frontend paths proxy to the existing Mempool
|
||||
frontend; `/api/`, `/api/v1/`, `/health` and the WebSocket feed retain their
|
||||
indexer routes. Version 1.0.1 served only service JSON at `/`. The candidate
|
||||
remains pending deployment/release acceptance, which must cover assets and deep links,
|
||||
desktop/mobile rendering, WebSocket updates, API/CORS/broadcast, trusted HTTPS,
|
||||
restart/upgrade and management-access isolation before documenting it as shipped.
|
||||
@@ -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,81 @@
|
||||
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;
|
||||
}
|
||||
upstream mempool_frontend {
|
||||
zone mempool_frontend 64k;
|
||||
server mempool:8080 resolve;
|
||||
}
|
||||
map $http_upgrade $angor_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
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 $angor_connection_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
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;
|
||||
# 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/v1/ws {
|
||||
limit_except GET { deny all; }
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
location /api/ {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
# Share the already-installed explorer; no second frontend or index DB.
|
||||
# Its SPA handles transaction/block deep links and static assets.
|
||||
location / {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_frontend;
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 =503 @waiting;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
app:
|
||||
id: angor-indexer
|
||||
name: Angor Indexer
|
||||
version: 1.0.2
|
||||
description: Bitcoin indexer endpoint for Angor with the existing Mempool explorer.
|
||||
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
|
||||
- mempool-api
|
||||
upstream:
|
||||
kind: github
|
||||
repo: block-core/angor
|
||||
container:
|
||||
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
dependencies:
|
||||
- app_id: mempool
|
||||
version: '>=3.0.0'
|
||||
- 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, or open it
|
||||
to view the existing Mempool explorer. HTTPS is required for browser clients.
|
||||
type: api
|
||||
port: 8998
|
||||
protocol: http
|
||||
path: /
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1: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-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/block-core/angor
|
||||
features:
|
||||
- Angor mainnet API
|
||||
- Mempool explorer on the same origin
|
||||
- Reuses existing Mempool indexing
|
||||
- No separate blockchain database
|
||||
- Optional independent relay
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
|
||||
For a public domain, proxy HTTPS to node port **8091**, enable WebSocket upgrade,
|
||||
and add `wss://your-relay-domain/` in Angor. Test both NIP-11 metadata (send
|
||||
`Accept: application/nostr+json`) and a real Nostr subscription over WSS. An
|
||||
Archipelago login page at this domain is a routing failure, not relay readiness.
|
||||
|
||||
New relays start without project history. Keep existing discovery relays alongside
|
||||
yours until the needed original signed announcements and metadata are available
|
||||
locally. Relays do not automatically synchronize. Any history import must retain
|
||||
the original event IDs and signatures; verify funded projects against their
|
||||
on-chain commitments. A working WebSocket with zero stored events is not proof
|
||||
that the client's project discovery works. See the indexer README's browser test.
|
||||
|
||||
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-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/hoytech/strfry
|
||||
features:
|
||||
- Angor project metadata
|
||||
- Separate from the node relay
|
||||
- Persistent Nostr event storage
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
+15
-8
@@ -15,6 +15,9 @@ app:
|
||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||
pull_policy: if-not-present
|
||||
|
||||
# Preserve repositories, database, keys and configuration during runtime repairs.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
@@ -25,7 +28,7 @@ app:
|
||||
disk_limit: 50Gi
|
||||
|
||||
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
|
||||
no_new_privileges: false
|
||||
network_policy: bridge
|
||||
@@ -62,6 +65,17 @@ app:
|
||||
target: /etc/gitea
|
||||
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:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
@@ -106,10 +120,3 @@ app:
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
app:
|
||||
id: nginx-proxy-manager
|
||||
name: Nginx Proxy Manager
|
||||
version: 2.12.1
|
||||
version: 2.14.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: NginxProxyManager/nginx-proxy-manager
|
||||
description: >-
|
||||
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
||||
On a node, this manages its admin UI and upstream configuration — the
|
||||
proxy's own :80/:443 listeners are not published (the node's web server
|
||||
owns those ports).
|
||||
The node's public web server forwards configured domains through this
|
||||
service, preserving its access lists, certificates and custom routes.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
# Rootless pasta copies the LAN IP, preventing requests back to this node.
|
||||
# Retain the old pasta host gateway used by saved NPM upstreams, plus
|
||||
# host.containers.internal. This subnet stays inside the private rootless namespace.
|
||||
network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -49,6 +52,17 @@ app:
|
||||
Nginx Proxy Manager enforces its own admin account on every page;
|
||||
the initial setup wizard also has to answer before any account exists.
|
||||
|
||||
- host: 8088
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
- host: 8444
|
||||
container: 443
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager
|
||||
@@ -64,9 +78,11 @@ app:
|
||||
|
||||
environment: []
|
||||
|
||||
# Probe the admin API inside the container, independent of optional
|
||||
# tunnel listeners. This also verifies the Node backend is ready.
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:81
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:81/api/
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -14,8 +14,16 @@ app:
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||
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"
|
||||
|
||||
# Snapshot state before an upgrade recreates this app with new networking.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+5
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -3636,9 +3636,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.44.2"
|
||||
version = "0.44.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
||||
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
@@ -3681,9 +3681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay-pool"
|
||||
version = "0.44.0"
|
||||
version = "0.44.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
|
||||
checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -74,7 +74,7 @@ impl ApiHandler {
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
@@ -98,6 +98,46 @@ impl ApiHandler {
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Payment settlement is verified on the seller even when no status
|
||||
// poll preceded this download (e.g. direct payment from another node).
|
||||
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
|
||||
content_server::load_catalog(&config.data_dir)
|
||||
.await?
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item.id == content_id
|
||||
&& matches!(item.access, content_server::AccessControl::Paid { .. })
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if requires_payment {
|
||||
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
|
||||
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid payment hash"),
|
||||
));
|
||||
}
|
||||
if let Err(error) = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(hash, content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
@@ -162,11 +202,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."}"#,
|
||||
),
|
||||
)),
|
||||
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,
|
||||
"text/plain",
|
||||
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"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +289,13 @@ impl ApiHandler {
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&payment_hash,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
@@ -268,52 +336,10 @@ impl ApiHandler {
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
Ok(invoice_status_response(path, |hash, id| async move {
|
||||
self.rpc_handler.settle_content_invoice(&hash, &id).await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
@@ -367,7 +393,13 @@ impl ApiHandler {
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&address,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
@@ -417,7 +449,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
@@ -428,10 +460,11 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
let mut paid =
|
||||
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
@@ -509,3 +542,109 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep invalid input and an unavailable wallet inside the HTTP protocol so
|
||||
/// buyers can retry delivery without treating a dropped socket as lost payment.
|
||||
async fn invoice_status_response<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
|
||||
where
|
||||
F: FnOnce(String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
let parsed = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|rest| rest.split_once("/invoice-status/"))
|
||||
.filter(|(id, hash)| {
|
||||
!id.is_empty()
|
||||
&& is_valid_app_id(id)
|
||||
&& hash.len() == 64
|
||||
&& hash.bytes().all(|c| c.is_ascii_hexdigit())
|
||||
});
|
||||
let Some((id, hash)) = parsed else {
|
||||
return build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invalid content ID or payment hash"}"#),
|
||||
);
|
||||
};
|
||||
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
|
||||
Ok(paid) => build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({"paid": paid}).to_string()),
|
||||
),
|
||||
Err(_) => {
|
||||
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
|
||||
let mut response = build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry without paying again."}"#,
|
||||
),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
hyper::header::RETRY_AFTER,
|
||||
hyper::header::HeaderValue::from_static("5"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod invoice_status_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_requests_do_not_query_the_wallet() {
|
||||
for path in [
|
||||
"/bad",
|
||||
"/content//invoice-status/aa",
|
||||
"/content/file/invoice-status/aa",
|
||||
"/content/file/invoice-status/",
|
||||
"/content/file/invoice-status/not-a-hash",
|
||||
] {
|
||||
let response = invoice_status_response(path, |_, _| async {
|
||||
panic!("Invalid request reached wallet");
|
||||
#[allow(unreachable_code)]
|
||||
Ok(false)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.headers()["content-type"], "application/json");
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"].is_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_results_and_failures_have_explicit_http_responses() {
|
||||
let hash = "AB".repeat(32);
|
||||
let path = format!("/content/file/invoice-status/{hash}");
|
||||
for paid in [false, true] {
|
||||
let response = invoice_status_response(&path, |hash, id| async move {
|
||||
assert_eq!(hash, "ab".repeat(32));
|
||||
assert_eq!(id, "file");
|
||||
Ok(paid)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["paid"],
|
||||
paid
|
||||
);
|
||||
}
|
||||
let response = invoice_status_response(&path, |_, _| async {
|
||||
anyhow::bail!("private wallet details must not escape")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.headers()["retry-after"], "5");
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(text.contains("without paying again"));
|
||||
assert!(!text.contains("private wallet"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -136,7 +136,7 @@ impl RpcHandler {
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
|
||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,16 +32,120 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve known purchases BEFORE any mint/spend. Missing bytes or an unreadable
|
||||
// index require recovery; neither is authorization to charge the buyer again.
|
||||
async fn existing_paid_content(
|
||||
data_dir: &std::path::Path,
|
||||
onion: &str,
|
||||
content_id: &str,
|
||||
filename: Option<&str>,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
let owned = crate::content_owned::list_owned_checked(data_dir)
|
||||
.await
|
||||
.context("Could not verify previous purchases; no new payment was sent")?;
|
||||
let Some(item) = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty() && o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (mime, bytes) = crate::content_owned::read_owned(data_dir, &item.onion, &item.content_id)
|
||||
.await.context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Restore the cached file or contact the seller.")?;
|
||||
let mut response = paid_content_response(&bytes, &mime, 0);
|
||||
response["already_owned"] = serde_json::json!(true);
|
||||
response["filename"] = serde_json::json!(item.filename);
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
// Updated clients open the persisted file through the Range-capable HTTP
|
||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
||||
// Keep older clients compatible until both sides have upgraded.
|
||||
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
|
||||
if cache_only {
|
||||
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
|
||||
} else {
|
||||
paid_content_response(bytes, mime, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List content I'm sharing.
|
||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||
@@ -441,43 +545,15 @@ impl RpcHandler {
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
if let Some(cached) = existing_paid_content(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
params.get("filename").and_then(|v| v.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
@@ -545,31 +621,30 @@ impl RpcHandler {
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
// A bearer token must not be replayed after an ambiguous delivery.
|
||||
// A transport error can mean the seller received it without replying.
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The purchase could not be completed. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
@@ -583,25 +658,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -609,9 +676,9 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -625,10 +692,17 @@ impl RpcHandler {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
let bytes = match response.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
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
|
||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||
@@ -658,63 +732,21 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||
),
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
@@ -856,10 +888,29 @@ impl RpcHandler {
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let cache_only = params
|
||||
.get("cache_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
|
||||
{
|
||||
return Ok(invoice_download_response(&bytes, &mime, cache_only));
|
||||
}
|
||||
// Older sellers only mark settlement during status polling. Always
|
||||
// perform that handshake before requesting bytes; retries never pay.
|
||||
// The download gate remains authoritative: a file may have become
|
||||
// free, and newer sellers verify directly if status polling fails.
|
||||
let _ = self
|
||||
.handle_content_invoice_status(Some(serde_json::json!({
|
||||
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
|
||||
})))
|
||||
.await;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
@@ -898,7 +949,7 @@ impl RpcHandler {
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||
"error": "The seller has not confirmed access yet. Retry the download without paying again."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
@@ -907,16 +958,45 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
let mime = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
.context("Paid file transfer interrupted; retry the download without paying again")?;
|
||||
let filename = params
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(content_id);
|
||||
crate::content_owned::record_purchase(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
filename,
|
||||
&mime,
|
||||
&bytes,
|
||||
params
|
||||
.get("price_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
"lightning",
|
||||
&chrono::Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.context("Paid file could not be saved; retry the download without paying again")?;
|
||||
if let Err(error) =
|
||||
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
|
||||
{
|
||||
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
|
||||
}
|
||||
Ok(invoice_download_response(&bytes, &mime, cache_only))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||
@@ -1387,3 +1467,23 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod invoice_delivery_response_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
|
||||
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
|
||||
assert_eq!(cached["owned"], true);
|
||||
assert_eq!(cached["size_bytes"], 10);
|
||||
assert!(cached.get("data").is_none());
|
||||
assert!(cached.get("data_base64").is_none());
|
||||
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
|
||||
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
|
||||
assert_eq!(legacy["data"], legacy["data_base64"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
crate::content_owned::record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"id",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"paid",
|
||||
1,
|
||||
"cashu",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
|
||||
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cached["paid_sats"], 0);
|
||||
assert_eq!(cached["already_owned"], true);
|
||||
assert_eq!(cached["data"], "cGFpZA==");
|
||||
}
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "different.onion", "id", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No new payment")
|
||||
);
|
||||
tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "other-id", None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no new payment")
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("purchased-content/owned.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"damaged"
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,9 @@ impl RpcHandler {
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
|
||||
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
|
||||
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::identity_manager::{
|
||||
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
|
||||
};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let is_default = default_id.as_deref() == Some(&id.id);
|
||||
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
|
||||
let is_node = is_node_identity(&id, &node_pubkey_hex);
|
||||
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
|
||||
@@ -272,28 +272,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
@@ -473,6 +453,7 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let fee_query = close_channel_fee_query(¶ms)?;
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -498,13 +479,11 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.query(&fee_query)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -572,3 +551,106 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
||||
/// explicit next-block target rather than silently accepting that default.
|
||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
||||
let force = match params.get("force") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
|
||||
};
|
||||
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
|
||||
match params.get(key) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(value) => {
|
||||
let n = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
|
||||
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = integer("target_conf", 1008)?;
|
||||
let rate = integer("sat_per_vbyte", 5000)?;
|
||||
anyhow::ensure!(
|
||||
target.is_none() || rate.is_none(),
|
||||
"Specify either target_conf or sat_per_vbyte, not both"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!force || (target.is_none() && rate.is_none()),
|
||||
"Closing fee selection requires a cooperative close"
|
||||
);
|
||||
let mut query = vec![("force", force.to_string())];
|
||||
if !force {
|
||||
if let Some(rate) = rate {
|
||||
query.push(("sat_per_vbyte", rate.to_string()));
|
||||
} else {
|
||||
query.push((
|
||||
"target_conf",
|
||||
target
|
||||
.unwrap_or(super::fee_policy::DEFAULT_TARGET as u64)
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod close_fee_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
|
||||
for target in [1, 3, 6, 1008] {
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
|
||||
vec![
|
||||
("force", "false".into()),
|
||||
("target_conf", target.to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
for rate in [1, 25, 5000] {
|
||||
let query =
|
||||
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
|
||||
let request = reqwest::Client::new()
|
||||
.delete("http://localhost/v1/channels/test/0")
|
||||
.query(&query)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::DELETE);
|
||||
assert_eq!(
|
||||
request.url().query(),
|
||||
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({})).unwrap(),
|
||||
vec![("force", "false".into()), ("target_conf", "1".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
|
||||
vec![("force", "true".into())]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
|
||||
for params in [
|
||||
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
|
||||
serde_json::json!({"force":true,"target_conf":1}),
|
||||
serde_json::json!({"force":"false"}),
|
||||
serde_json::json!({"target_conf":0}),
|
||||
serde_json::json!({"target_conf":1009}),
|
||||
serde_json::json!({"sat_per_vbyte":5001}),
|
||||
serde_json::json!({"sat_per_vbyte":-1}),
|
||||
serde_json::json!({"sat_per_vbyte":1.5}),
|
||||
serde_json::json!({"sat_per_vbyte":"25"}),
|
||||
] {
|
||||
assert!(close_channel_fee_query(¶ms).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
//! WalletKit BumpFee is CPFP for new wallet outputs, RBF only for sweeper inputs.
|
||||
//! Never feed an ordinary payment input to it and call that a replacement.
|
||||
use super::LND_REST_BASE_URL;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{collections::HashMap, path::Path, sync::LazyLock};
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
static QUOTES: LazyLock<Mutex<HashMap<String, Quote>>> = LazyLock::new(Default::default);
|
||||
// Serialize check/register/persist across dashboard clients. The create_new receipt
|
||||
// additionally survives process restarts and prevents retries of ambiguous results.
|
||||
static SUBMIT: Mutex<()> = Mutex::const_new(());
|
||||
const QUOTE_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct Plan {
|
||||
txid: String,
|
||||
method: String,
|
||||
input_txid: String,
|
||||
input_index: u32,
|
||||
parent_txid: String,
|
||||
recipient_sats: u64,
|
||||
rate_sat_vb: u64,
|
||||
current_fee_sats: u64,
|
||||
additional_fee_sats: u64,
|
||||
total_fee_sats: u64,
|
||||
budget_sats: u64,
|
||||
input_sats: u64,
|
||||
parent_vsize: u64,
|
||||
sweep_vsize_bound: u64,
|
||||
tip: String,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Quote {
|
||||
quote_id: String,
|
||||
expires_at: u64,
|
||||
custom_rate: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
plan: Plan,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Operation {
|
||||
quote: Quote,
|
||||
status: String,
|
||||
message: String,
|
||||
}
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
fn number(v: &Value) -> Result<u64> {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
.context("Missing or invalid wallet amount")
|
||||
}
|
||||
fn txid_param(p: &Value) -> Result<String> {
|
||||
let s = p["txid"].as_str().context("Missing transaction ID")?;
|
||||
ensure!(
|
||||
s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid transaction ID"
|
||||
);
|
||||
Ok(s.to_ascii_lowercase())
|
||||
}
|
||||
fn btc_sats(v: &Value) -> Result<u64> {
|
||||
let n = v.as_f64().context("Missing Bitcoin fee")? * 100_000_000.0;
|
||||
ensure!(
|
||||
n.is_finite() && n >= 0.0 && n <= 2_100_000_000_000_000.0,
|
||||
"Invalid Bitcoin fee"
|
||||
);
|
||||
Ok(n.round() as u64)
|
||||
}
|
||||
fn outpoint_matches(v: &Value, txid: &str, index: u32) -> bool {
|
||||
v["txid_str"].as_str() == Some(txid) && v["output_index"].as_u64() == Some(index as u64)
|
||||
}
|
||||
fn array<'a>(v: &'a Value, key: &str) -> Result<&'a Vec<Value>> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.with_context(|| format!("Missing wallet field: {key}"))
|
||||
}
|
||||
fn sweep_size(output: &Value) -> Result<u64> {
|
||||
// One native input, one wallet taproot output, including signature rounding.
|
||||
match output["output_type"].as_str() {
|
||||
Some("SCRIPT_TYPE_WITNESS_V1_TAPROOT") => Ok(112),
|
||||
Some("SCRIPT_TYPE_WITNESS_V0_PUBKEY_HASH") => Ok(123),
|
||||
_ => bail!("This output type is not supported for fee bumping yet"),
|
||||
}
|
||||
}
|
||||
fn fee_budget(
|
||||
rate: u64,
|
||||
parent_size: u64,
|
||||
parent_fee: u64,
|
||||
size: u64,
|
||||
old_fee: u64,
|
||||
relay: u64,
|
||||
input: u64,
|
||||
) -> Result<u64> {
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Fee rate must be a whole number from 1 to 5000 sat/vB"
|
||||
);
|
||||
ensure!(
|
||||
parent_size <= 100_000 && size <= 100_000 && relay <= 5000,
|
||||
"Unsupported package size or relay fee"
|
||||
);
|
||||
let required = rate * (parent_size + size);
|
||||
let mut budget = required.saturating_sub(parent_fee).max(relay * size);
|
||||
if old_fee > 0 {
|
||||
budget = budget.max(old_fee + relay * size + 1);
|
||||
}
|
||||
ensure!(budget > old_fee, "Choose a higher fee rate");
|
||||
// Conservative dust buffer; never attach unrelated wallet inputs to fund fees.
|
||||
ensure!(
|
||||
budget.checked_add(1000).is_some_and(|v| v <= input),
|
||||
"Not enough wallet change for this fee; choose a lower rate"
|
||||
);
|
||||
Ok(budget)
|
||||
}
|
||||
|
||||
async fn lnd(
|
||||
client: &reqwest::Client,
|
||||
macaroon: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let req = match body {
|
||||
Some(v) => client.post(url).json(&v),
|
||||
None => client.get(url),
|
||||
};
|
||||
let response = req
|
||||
.header("Grpc-Metadata-macaroon", macaroon)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.context("Invalid LND response")?;
|
||||
ensure!(
|
||||
status.is_success() && value.get("code").is_none(),
|
||||
"{}",
|
||||
value["message"].as_str().unwrap_or("LND request failed")
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_quote(quote: &Quote, fresh: &Plan, timestamp: u64) -> Result<()> {
|
||||
ensure!(
|
||||
quote.expires_at > timestamp && quote.plan == *fresh,
|
||||
"Transaction or fees changed; review a fresh quote"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn bump_body(plan: &Plan) -> Value {
|
||||
json!({"outpoint":{"txid_str":plan.input_txid,"output_index":plan.input_index},
|
||||
"sat_per_vbyte":plan.rate_sat_vb.to_string(), "budget":plan.budget_sats.to_string(),
|
||||
"deadline_delta":1, "immediate":true})
|
||||
}
|
||||
|
||||
async fn reserve(path: &Path, op: &Operation) -> Result<()> {
|
||||
let parent = path.parent().context("Invalid operation path")?;
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.await
|
||||
.context("A bump already exists for this transaction; check its status")?;
|
||||
f.write_all(&serde_json::to_vec(op)?).await?;
|
||||
f.sync_all().await?;
|
||||
// Sync directory entry too: a crash must not make a submitted operation vanish.
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Only a recorded Archy CPFP with one owned input and no external outputs may
|
||||
// be folded into a payment. Labels and a fee-sized delta alone are not evidence.
|
||||
fn fee_child_matches(tx: &Value, plan: &Plan) -> bool {
|
||||
let input = format!("{}:{}", plan.input_txid, plan.input_index);
|
||||
let amount = tx["amount"]
|
||||
.as_i64()
|
||||
.or_else(|| tx["amount"].as_str()?.parse().ok());
|
||||
let fee = number(&tx["total_fees"])
|
||||
.ok()
|
||||
.and_then(|n| i64::try_from(n).ok());
|
||||
tx["tx_hash"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
&& amount
|
||||
.zip(fee)
|
||||
.is_some_and(|(amount, fee)| fee > 0 && amount == -fee)
|
||||
&& tx["previous_outpoints"].as_array().is_some_and(|inputs| {
|
||||
inputs.len() == 1
|
||||
&& inputs[0]["outpoint"] == input
|
||||
&& inputs[0]["is_our_output"] == true
|
||||
})
|
||||
&& tx["output_details"].as_array().is_some_and(|outputs| {
|
||||
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
|
||||
})
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn group_fee_bump_history(
|
||||
&self,
|
||||
raw: &[Value],
|
||||
normalized: &mut Vec<Value>,
|
||||
client: &reqwest::Client,
|
||||
) {
|
||||
let mut hidden = std::collections::HashSet::new();
|
||||
for parent in normalized.iter_mut() {
|
||||
if parent["direction"] != "outgoing" {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = parent["tx_hash"].as_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if id.len() != 64 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
continue;
|
||||
}
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{id}.json"));
|
||||
let Ok(bytes) = tokio::fs::read(path).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(op) = serde_json::from_slice::<Operation>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let plan = &op.quote.plan;
|
||||
if plan.method != "cpfp"
|
||||
|| plan.txid != id
|
||||
|| plan.parent_txid != id
|
||||
|| plan.input_txid != id
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<_> = raw
|
||||
.iter()
|
||||
.filter(|tx| fee_child_matches(tx, plan))
|
||||
.collect();
|
||||
let mut active = Vec::new();
|
||||
for child in &candidates {
|
||||
let child_id = child["tx_hash"].as_str().unwrap();
|
||||
if child["num_confirmations"].as_i64().unwrap_or(0) > 0
|
||||
|| self
|
||||
.bitcoin_rpc_call::<Value>(client, "getmempoolentry", &[json!(child_id)])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
active.push(*child);
|
||||
}
|
||||
}
|
||||
// Ambiguous or unavailable chain state must not hide wallet history.
|
||||
if active.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let current = active[0];
|
||||
parent["bump_fee_sats"] = json!(number(¤t["total_fees"]).unwrap());
|
||||
parent["fee_bump_txid"] = current["tx_hash"].clone();
|
||||
parent["fee_bump_confirmations"] = current["num_confirmations"].clone();
|
||||
parent["fee_bump_history"] = json!(candidates.iter().map(|child| {
|
||||
let child_id = child["tx_hash"].as_str().unwrap();
|
||||
hidden.insert(child_id.to_owned());
|
||||
json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(),
|
||||
"status":if child["tx_hash"] != current["tx_hash"] { "replaced" }
|
||||
else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }})
|
||||
}).collect::<Vec<_>>());
|
||||
}
|
||||
normalized.retain(|tx| !tx["tx_hash"].as_str().is_some_and(|id| hidden.contains(id)));
|
||||
}
|
||||
|
||||
async fn bump_plan(&self, txid: &str, custom_rate: Option<u64>) -> Result<Plan> {
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
let info = lnd(&client, &macaroon, "/v1/getinfo", None).await?;
|
||||
ensure!(
|
||||
info["synced_to_chain"] == true,
|
||||
"Wait for the wallet to finish syncing"
|
||||
);
|
||||
let version = info["version"]
|
||||
.as_str()
|
||||
.context("LND version is unavailable")?;
|
||||
let mut parts = version.trim_start_matches('v').split('.');
|
||||
let major: u32 = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.context("Invalid LND version")?;
|
||||
let minor: u32 = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.context("Invalid LND version")?;
|
||||
ensure!(
|
||||
major > 0 || minor >= 21,
|
||||
"This fee-bump interface requires LND 0.21 or newer"
|
||||
);
|
||||
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
|
||||
let txs = array(&history, "transactions")?;
|
||||
let tx = txs
|
||||
.iter()
|
||||
.find(|t| t["tx_hash"] == txid)
|
||||
.context("Transaction is not in this wallet")?;
|
||||
ensure!(
|
||||
tx["num_confirmations"].as_i64() == Some(0),
|
||||
"This transaction is no longer pending"
|
||||
);
|
||||
let entry: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(txid)])
|
||||
.await
|
||||
.context("Transaction is not currently in the node's mempool")?;
|
||||
ensure!(
|
||||
number(&entry["descendantcount"])? == 1,
|
||||
"This transaction already has a child; open the child's Bump options instead"
|
||||
);
|
||||
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
|
||||
let sweeps = array(&pending, "pending_sweeps")?;
|
||||
let published = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/sweeps?verbose=false&start_height=-1",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let is_sweep = published["transaction_ids"]["transaction_ids"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.iter().any(|id| id == txid));
|
||||
let outputs = array(tx, "output_details")?;
|
||||
ensure!(
|
||||
tx["amount"]
|
||||
.as_str()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.or_else(|| tx["amount"].as_i64())
|
||||
.is_some_and(|v| v < 0),
|
||||
"Bump is available for outgoing payments and wallet fee sweeps"
|
||||
);
|
||||
let (
|
||||
method,
|
||||
input_txid,
|
||||
input_index,
|
||||
input_sats,
|
||||
parent_txid,
|
||||
parent_size,
|
||||
parent_fee,
|
||||
old_fee,
|
||||
size,
|
||||
recipient_sats,
|
||||
) = if is_sweep {
|
||||
// Only a simple wallet CPFP sweep is replaceable here. Anchor/HTLC,
|
||||
// batched sweeps and arbitrary signed payments need different previews.
|
||||
let raw: Value = self
|
||||
.bitcoin_rpc_call(&client, "getrawtransaction", &[json!(txid), json!(true)])
|
||||
.await?;
|
||||
let inputs = array(&raw, "vin")?;
|
||||
ensure!(
|
||||
inputs.len() == 1 && outputs.len() == 1 && outputs[0]["is_our_address"] == true,
|
||||
"RBF for batched or channel sweeps is not supported here yet"
|
||||
);
|
||||
let input_txid = inputs[0]["txid"]
|
||||
.as_str()
|
||||
.context("Missing sweep input")?
|
||||
.to_string();
|
||||
let index = u32::try_from(number(&inputs[0]["vout"])?)?;
|
||||
ensure!(
|
||||
sweeps.len() == 1 && outpoint_matches(&sweeps[0]["outpoint"], &input_txid, index),
|
||||
"RBF is unavailable while other wallet sweeps are active"
|
||||
);
|
||||
let parent = txs
|
||||
.iter()
|
||||
.find(|t| t["tx_hash"] == input_txid)
|
||||
.context("Sweep parent is unavailable")?;
|
||||
let parent_output = array(parent, "output_details")?
|
||||
.iter()
|
||||
.find(|o| {
|
||||
number(&o["output_index"]).ok() == Some(index as u64)
|
||||
&& o["is_our_address"] == true
|
||||
})
|
||||
.context("RBF requires a wallet-owned change input")?;
|
||||
let parent_entry: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(input_txid)])
|
||||
.await
|
||||
.context("Only unconfirmed CPFP sweep replacements are supported here")?;
|
||||
ensure!(
|
||||
number(&parent_entry["ancestorcount"])? == 1
|
||||
&& number(&parent_entry["descendantcount"])? == 2,
|
||||
"Complex sweep package cannot be quoted safely"
|
||||
);
|
||||
let recipients = recipient_amount(parent)?;
|
||||
(
|
||||
"rbf",
|
||||
input_txid.clone(),
|
||||
index,
|
||||
number(&parent_output["amount"])?,
|
||||
input_txid,
|
||||
number(&parent_entry["vsize"])?,
|
||||
btc_sats(&parent_entry["fees"]["base"])?,
|
||||
btc_sats(&entry["fees"]["base"])?,
|
||||
sweep_size(parent_output)?.max(number(&entry["vsize"])?),
|
||||
recipients,
|
||||
)
|
||||
} else {
|
||||
ensure!(
|
||||
sweeps.is_empty(),
|
||||
"Another wallet sweep is active; wait for it before creating a CPFP bump"
|
||||
);
|
||||
ensure!(
|
||||
number(&entry["ancestorcount"])? == 1,
|
||||
"Fee bumping a chain of unconfirmed payments is not supported yet"
|
||||
);
|
||||
let unspent = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/utxos",
|
||||
Some(json!({"unconfirmed_only":true})),
|
||||
)
|
||||
.await?;
|
||||
let utxos = array(&unspent, "utxos")?;
|
||||
let leases = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/utxos/leases",
|
||||
Some(json!({})),
|
||||
)
|
||||
.await?;
|
||||
let locked = array(&leases, "locked_utxos")?;
|
||||
let output = outputs
|
||||
.iter()
|
||||
.filter(|o| o["is_our_address"] == true && sweep_size(o).is_ok())
|
||||
.filter(|o| {
|
||||
number(&o["output_index"]).ok().is_some_and(|i| {
|
||||
utxos
|
||||
.iter()
|
||||
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
|
||||
&& !locked
|
||||
.iter()
|
||||
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
|
||||
})
|
||||
})
|
||||
.max_by_key(|o| number(&o["amount"]).unwrap_or(0))
|
||||
.context(
|
||||
"RBF is unavailable for this payment. CPFP needs spendable wallet-owned change",
|
||||
)?;
|
||||
let index = u32::try_from(number(&output["output_index"])?)?;
|
||||
let available: Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"gettxout",
|
||||
&[json!(txid), json!(index), json!(true)],
|
||||
)
|
||||
.await?;
|
||||
ensure!(
|
||||
available.is_object()
|
||||
&& number(&available["confirmations"])? == 0
|
||||
&& btc_sats(&available["value"])? == number(&output["amount"])?,
|
||||
"Change is no longer available"
|
||||
);
|
||||
(
|
||||
"cpfp",
|
||||
txid.to_string(),
|
||||
index,
|
||||
number(&output["amount"])?,
|
||||
txid.to_string(),
|
||||
number(&entry["vsize"])?,
|
||||
btc_sats(&entry["fees"]["base"])?,
|
||||
0,
|
||||
sweep_size(output)?,
|
||||
recipient_amount(tx)?,
|
||||
)
|
||||
};
|
||||
let mempool: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolinfo", &[])
|
||||
.await?;
|
||||
let relay = btc_sats(&mempool["incrementalrelayfee"])?
|
||||
.div_ceil(1000)
|
||||
.max(1);
|
||||
let floor = btc_sats(&mempool["mempoolminfee"])?
|
||||
.max(btc_sats(&mempool["minrelaytxfee"])?)
|
||||
.div_ceil(1000)
|
||||
.max(1);
|
||||
let rate = match custom_rate {
|
||||
Some(rate) => {
|
||||
ensure!(
|
||||
rate >= floor,
|
||||
"Custom rate is below the current mempool minimum"
|
||||
);
|
||||
rate
|
||||
}
|
||||
None => {
|
||||
let estimate = lnd(&client, &macaroon, "/v2/wallet/estimatefee/1", None).await?;
|
||||
number(&estimate["sat_per_kw"])?.div_ceil(250).max(floor)
|
||||
}
|
||||
};
|
||||
let budget = fee_budget(
|
||||
rate,
|
||||
parent_size,
|
||||
parent_fee,
|
||||
size,
|
||||
old_fee,
|
||||
relay.max(floor),
|
||||
input_sats,
|
||||
)?;
|
||||
let tip: String = self
|
||||
.bitcoin_rpc_call(&client, "getbestblockhash", &[])
|
||||
.await?;
|
||||
Ok(Plan {
|
||||
txid: txid.to_string(),
|
||||
method: method.into(),
|
||||
input_txid,
|
||||
input_index,
|
||||
parent_txid,
|
||||
recipient_sats,
|
||||
rate_sat_vb: rate,
|
||||
current_fee_sats: parent_fee + old_fee,
|
||||
additional_fee_sats: budget - old_fee,
|
||||
total_fee_sats: parent_fee + budget,
|
||||
budget_sats: budget,
|
||||
input_sats,
|
||||
parent_vsize: parent_size,
|
||||
sweep_vsize_bound: size,
|
||||
tip,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_quote(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let txid = txid_param(&p)?;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
ensure!(
|
||||
!path.try_exists()?,
|
||||
"A bump was already submitted for this transaction. Check its status"
|
||||
);
|
||||
let custom = p
|
||||
.get("sat_per_vbyte")
|
||||
.map(|v| v.as_u64().context("Custom rate must be a whole number"))
|
||||
.transpose()?;
|
||||
if let Some(rate) = custom {
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Custom rate must be 1–5000 sat/vB"
|
||||
);
|
||||
}
|
||||
let plan = self.bump_plan(&txid, custom).await?;
|
||||
let quote = Quote {
|
||||
quote_id: uuid::Uuid::new_v4().to_string(),
|
||||
expires_at: now() + QUOTE_SECONDS,
|
||||
custom_rate: custom,
|
||||
plan,
|
||||
};
|
||||
let mut quotes = QUOTES.lock().await;
|
||||
quotes.retain(|_, q| q.expires_at > now());
|
||||
ensure!(quotes.len() < 128, "Too many fee quotes; try again shortly");
|
||||
quotes.insert(quote.quote_id.clone(), quote.clone());
|
||||
Ok(serde_json::to_value(quote)?)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_submit(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let txid = txid_param(&p)?;
|
||||
let _guard = SUBMIT.lock().await;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
if path.try_exists()? {
|
||||
return self
|
||||
.handle_lnd_bump_status(Some(json!({"txid":txid})))
|
||||
.await;
|
||||
}
|
||||
let id = p["quote_id"]
|
||||
.as_str()
|
||||
.context("A reviewed fee quote is required")?;
|
||||
let quote = QUOTES
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.cloned()
|
||||
.context("Quote expired; review the fee again")?;
|
||||
ensure!(
|
||||
quote.plan.txid == txid && quote.expires_at > now(),
|
||||
"Quote expired; review the fee again"
|
||||
);
|
||||
let fresh = self.bump_plan(&txid, quote.custom_rate).await?;
|
||||
validate_quote("e, &fresh, now())?;
|
||||
let op = Operation {
|
||||
quote: quote.clone(),
|
||||
status: "unknown".into(),
|
||||
message: "Submission recorded; checking the wallet. Do not submit another bump.".into(),
|
||||
};
|
||||
reserve(&path, &op).await?;
|
||||
QUOTES.lock().await.remove(id);
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
// At a one-block deadline LND may spend ALL this explicitly previewed
|
||||
// budget. It is always below input value, so no extra funding is requested.
|
||||
let result = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/bumpfee",
|
||||
Some(bump_body(&fresh)),
|
||||
)
|
||||
.await;
|
||||
// Keep the write-ahead record even for an RPC error: a lost response can
|
||||
// conceal an accepted bump. Status reconciles from wallet/mempool evidence.
|
||||
match result {
|
||||
Ok(_) => Ok(
|
||||
json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}),
|
||||
),
|
||||
Err(_) => Ok(
|
||||
json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_status(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let txid = txid_param(¶ms.unwrap_or_default())?;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(json!({"status":"none"}))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let op: Operation = serde_json::from_slice(&bytes)
|
||||
.context("Bump receipt needs recovery; do not resubmit")?;
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
|
||||
let plan = &op.quote.plan;
|
||||
let input = format!("{}:{}", plan.input_txid, plan.input_index);
|
||||
let mut candidates: Vec<&Value> = array(&history, "transactions")?
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t["tx_hash"] != txid
|
||||
&& t["output_details"].as_array().is_some_and(|outputs| {
|
||||
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
|
||||
})
|
||||
&& t["previous_outpoints"]
|
||||
.as_array()
|
||||
.is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input))
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0)));
|
||||
for t in candidates {
|
||||
let id = t["tx_hash"]
|
||||
.as_str()
|
||||
.context("Missing bump transaction ID")?;
|
||||
let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0;
|
||||
let accepted = if confirmed {
|
||||
false
|
||||
} else {
|
||||
self.bitcoin_rpc_call::<Value>(&client, "getmempoolentry", &[json!(id)])
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if confirmed || accepted {
|
||||
return Ok(json!({"status":if confirmed {"confirmed"} else {"mempool"},
|
||||
"message":if confirmed {"Fee bump confirmed."} else {"Fee bump accepted in the node's mempool; awaiting confirmation."},
|
||||
"bump_txid":id,"confirmations":t["num_confirmations"],"actual_sweep_fee_sats":number(&t["total_fees"])?,"quote":op.quote}));
|
||||
}
|
||||
}
|
||||
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
|
||||
let registered = array(&pending, "pending_sweeps")?.iter().any(|s| {
|
||||
outpoint_matches(&s["outpoint"], &plan.input_txid, plan.input_index)
|
||||
&& number(&s["budget"]).ok() == Some(plan.budget_sats)
|
||||
&& number(&s["requested_sat_per_vbyte"]).ok() == Some(plan.rate_sat_vb)
|
||||
});
|
||||
Ok(
|
||||
json!({"status":if registered {"registered"} else {"unknown"},
|
||||
"message":if registered {"Bump registered; waiting for a verified broadcast."} else {"Submission outcome is unknown. Do not submit again; check wallet status."}, "quote":op.quote}),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn recipient_amount(tx: &Value) -> Result<u64> {
|
||||
array(tx, "output_details")?
|
||||
.iter()
|
||||
.filter(|o| o["is_our_address"] == false)
|
||||
.try_fold(0u64, |sum, o| {
|
||||
sum.checked_add(number(&o["amount"])?)
|
||||
.context("Recipient amount overflow")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample_plan() -> Plan {
|
||||
serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":161650,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn history_requires_owned_simple_fee_only_child() {
|
||||
let plan = sample_plan();
|
||||
let tx = json!({"tx_hash":"b".repeat(64),"amount":"-200","total_fees":"200",
|
||||
"previous_outpoints":[{"outpoint":"a:0","is_our_output":true}],
|
||||
"output_details":[{"is_our_address":true}]});
|
||||
assert!(fee_child_matches(&tx, &plan));
|
||||
for bad in [
|
||||
json!({"amount":"-201"}),
|
||||
json!({"amount":"200"}),
|
||||
json!({"total_fees":"0"}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:1","is_our_output":true}]}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":false}]}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":true},{"outpoint":"c:0","is_our_output":true}]}),
|
||||
json!({"output_details":[{"is_our_address":false}]}),
|
||||
json!({"output_details":[]}),
|
||||
json!({"tx_hash":"../../invalid"}),
|
||||
] {
|
||||
let mut changed = tx.clone();
|
||||
for (key, value) in bad.as_object().unwrap() {
|
||||
changed[key] = value.clone();
|
||||
}
|
||||
assert!(!fee_child_matches(&changed, &plan), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stale_quotes_cannot_silently_change_approved_fee_or_transaction() {
|
||||
let plan = sample_plan();
|
||||
let q = Quote {
|
||||
quote_id: "q".into(),
|
||||
expires_at: 100,
|
||||
custom_rate: None,
|
||||
plan: plan.clone(),
|
||||
};
|
||||
assert!(validate_quote(&q, &plan, 99).is_ok());
|
||||
assert!(validate_quote(&q, &plan, 100).is_err());
|
||||
let mut changed = plan.clone();
|
||||
changed.budget_sats += 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.input_index += 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.recipient_sats -= 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.tip = "new block".into();
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mutation_always_has_explicit_budget_and_does_not_send_a_second_payment() {
|
||||
assert_eq!(
|
||||
bump_body(&sample_plan()),
|
||||
json!({"outpoint":{"txid_str":"a","output_index":0},"sat_per_vbyte":"3","budget":"618","deadline_delta":1,"immediate":true})
|
||||
);
|
||||
let mut rbf = sample_plan();
|
||||
rbf.method = "rbf".into();
|
||||
rbf.txid = "child".into();
|
||||
// RBF uses the already-registered input, not the child's output.
|
||||
assert_eq!(bump_body(&rbf)["outpoint"]["txid_str"], "a");
|
||||
}
|
||||
#[test]
|
||||
fn outpoint_ownership_and_recipient_exclude_wallet_change() {
|
||||
assert!(outpoint_matches(
|
||||
&json!({"txid_str":"a","output_index":2}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert!(!outpoint_matches(
|
||||
&json!({"txid_str":"b","output_index":2}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert!(!outpoint_matches(
|
||||
&json!({"txid_str":"a","output_index":3}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert_eq!(recipient_amount(&json!({"output_details":[{"is_our_address":true,"amount":"21126"},{"is_our_address":false,"amount":"161650"}]})).unwrap(), 161650);
|
||||
assert!(recipient_amount(
|
||||
&json!({"output_details":[{"is_our_address":false,"amount":"bad"}]})
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cpfp_budget_covers_parent_and_preserves_change() {
|
||||
assert_eq!(fee_budget(3, 142, 144, 112, 0, 1, 21126).unwrap(), 618);
|
||||
assert!(fee_budget(5000, 142, 144, 112, 0, 1, 21126).is_err());
|
||||
assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() {
|
||||
let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap();
|
||||
assert_eq!(fee, 763);
|
||||
assert_eq!(fee - 650, 113);
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_outputs_and_malformed_ids_fail_closed() {
|
||||
assert!(sweep_size(&json!({"output_type":"SCRIPT_TYPE_WITNESS_V0_SCRIPT_HASH"})).is_err());
|
||||
assert!(txid_param(&json!({"txid":"../../file"})).is_err());
|
||||
assert!(number(&json!(-1)).is_err());
|
||||
assert!(btc_sats(&json!(-0.1)).is_err());
|
||||
assert_eq!(btc_sats(&json!(0.00000650)).unwrap(), 650);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn receipt_prevents_duplicate_submission_after_restart() {
|
||||
let dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
let path = dir.join("receipt.json");
|
||||
let plan: Plan = serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":1000,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap();
|
||||
let op = Operation {
|
||||
quote: Quote {
|
||||
quote_id: "q".into(),
|
||||
expires_at: now() + 60,
|
||||
custom_rate: None,
|
||||
plan,
|
||||
},
|
||||
status: "unknown".into(),
|
||||
message: "pending".into(),
|
||||
};
|
||||
reserve(&path, &op).await.unwrap();
|
||||
assert!(reserve(&path, &op).await.is_err());
|
||||
let restored: Operation =
|
||||
serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap();
|
||||
assert_eq!(restored.quote.plan.budget_sats, 618);
|
||||
tokio::fs::remove_dir_all(dir).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Explicit on-chain fee choices retain priority; omitted choices target the next block.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(super) const DEFAULT_TARGET: i64 = 1;
|
||||
|
||||
pub(super) fn estimated_sat_per_vbyte(value: &Value) -> Result<u64> {
|
||||
let per_kw = value["sat_per_kw"]
|
||||
.as_u64()
|
||||
.or_else(|| value["sat_per_kw"].as_str().and_then(|s| s.parse().ok()))
|
||||
.context("Next-block fee estimate is unavailable")?;
|
||||
let rate = per_kw.div_ceil(250);
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Next-block fee estimate is outside supported bounds; choose an explicit fee"
|
||||
);
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
pub(super) fn fee_options(params: &Value) -> Result<(Option<i64>, Option<i64>)> {
|
||||
let integer = |key: &str, max: i64| -> Result<Option<i64>> {
|
||||
match params.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(value) => {
|
||||
let n = value
|
||||
.as_i64()
|
||||
.with_context(|| format!("{key} must be a positive whole number"))?;
|
||||
ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = integer("target_conf", 1008)?;
|
||||
let rate = integer("sat_per_vbyte", 5000)?;
|
||||
ensure!(
|
||||
target.is_none() || rate.is_none(),
|
||||
"Specify either target_conf or sat_per_vbyte, not both"
|
||||
);
|
||||
Ok((
|
||||
if rate.is_none() {
|
||||
Some(target.unwrap_or(DEFAULT_TARGET))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
rate,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn estimates_round_up_and_missing_or_extreme_estimates_fail_closed() {
|
||||
assert_eq!(
|
||||
estimated_sat_per_vbyte(&json!({"sat_per_kw":"501"})).unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
estimated_sat_per_vbyte(&json!({"sat_per_kw":250})).unwrap(),
|
||||
1
|
||||
);
|
||||
for v in [
|
||||
json!({}),
|
||||
json!({"sat_per_kw":0}),
|
||||
json!({"sat_per_kw":-1}),
|
||||
json!({"sat_per_kw":1250001}),
|
||||
] {
|
||||
assert!(estimated_sat_per_vbyte(&v).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_block_default_preserves_explicit_slower_and_custom_choices() {
|
||||
assert_eq!(fee_options(&json!({})).unwrap(), (Some(1), None));
|
||||
assert_eq!(
|
||||
fee_options(&json!({"target_conf":null})).unwrap(),
|
||||
(Some(1), None)
|
||||
);
|
||||
for target in [1, 3, 6, 144, 1008] {
|
||||
assert_eq!(
|
||||
fee_options(&json!({"target_conf":target})).unwrap(),
|
||||
(Some(target), None)
|
||||
);
|
||||
}
|
||||
for rate in [1, 17, 5000] {
|
||||
assert_eq!(
|
||||
fee_options(&json!({"sat_per_vbyte":rate})).unwrap(),
|
||||
(None, Some(rate))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_explicit_fees_never_silently_become_fast() {
|
||||
for value in [
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!("6"),
|
||||
json!(true),
|
||||
json!({}),
|
||||
json!(1009),
|
||||
] {
|
||||
assert!(fee_options(&json!({"target_conf":value})).is_err());
|
||||
}
|
||||
for value in [
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!("6"),
|
||||
json!(true),
|
||||
json!(5001),
|
||||
] {
|
||||
assert!(fee_options(&json!({"sat_per_vbyte":value})).is_err());
|
||||
}
|
||||
assert!(fee_options(&json!({"target_conf":1,"sat_per_vbyte":2})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
macaroon_hex: &str,
|
||||
) -> Result<T> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND is unavailable; balance could not be checked")?
|
||||
.error_for_status()
|
||||
.context("LND is not ready; balance could not be checked")?
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned invalid wallet data")
|
||||
}
|
||||
|
||||
fn checked_balances(
|
||||
wallet: LndBalanceResponse,
|
||||
channels: LndChannelBalanceResponse,
|
||||
) -> Result<(i64, i64, i64)> {
|
||||
fn sats(value: Option<String>) -> Result<i64> {
|
||||
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||
Ok(amount)
|
||||
}
|
||||
Ok((
|
||||
sats(wallet.total_balance)?,
|
||||
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||
checked_balances(wallet_balance, channel_balance)?;
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
balance_sats,
|
||||
channel_balance_sats,
|
||||
pending_open_balance,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_balances_are_not_zero() {
|
||||
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||
assert!(checked_balances(
|
||||
serde_json::from_str(body).unwrap(),
|
||||
serde_json::from_str(body).unwrap(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
for value in ["bad", "-1", "9223372036854775808"] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(value.into()),
|
||||
};
|
||||
let channels = serde_json::from_str(
|
||||
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked_balances(wallet, channels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_zero_and_nonzero_balances_survive() {
|
||||
for expected in [0, 42] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(expected.to_string()),
|
||||
};
|
||||
let channels = serde_json::from_value(serde_json::json!({
|
||||
"local_balance":{"sat":expected.to_string()},
|
||||
"pending_open_local_balance":{"sat":"0"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
checked_balances(wallet, channels).unwrap(),
|
||||
(expected, expected, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
stream.read(&mut buf).await.unwrap();
|
||||
let body =
|
||||
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/getinfo"),
|
||||
"test"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
@@ -341,3 +462,44 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod channels;
|
||||
mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
mod payments;
|
||||
@@ -133,12 +135,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -161,6 +187,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -182,7 +212,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||
let progressing = height
|
||||
.zip(last_height)
|
||||
.is_some_and(|(now, before)| now > before);
|
||||
last_height = height;
|
||||
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -239,3 +274,31 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +402,9 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
|
||||
.await;
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
@@ -124,28 +124,8 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
@@ -238,15 +218,12 @@ impl RpcHandler {
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?;
|
||||
anyhow::ensure!(
|
||||
custom_rate.is_none(),
|
||||
"Fee estimation requires a confirmation target"
|
||||
);
|
||||
let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
@@ -453,6 +430,56 @@ impl RpcHandler {
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Verify against LND at download time, rather than relying on a browser
|
||||
/// having polled first. The memo/amount also recover pre-upgrade in-memory
|
||||
/// entitlements after restart; unrelated invoices never unlock a file.
|
||||
pub(crate) async fn settle_content_invoice(
|
||||
&self,
|
||||
hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<bool> {
|
||||
anyhow::ensure!(
|
||||
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid payment hash"
|
||||
);
|
||||
let hash = hash.to_ascii_lowercase();
|
||||
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
|
||||
if let Some((id, _)) = &existing {
|
||||
if id != content_id {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
|
||||
return Ok(true);
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = response.error_for_status()?.json().await?;
|
||||
let Some(price) = content_invoice_amount(&body, content_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|(_, expected)| *expected != price)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
|
||||
.await?;
|
||||
let settled = content_invoice_fully_settled(&body, price);
|
||||
if settled {
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
|
||||
}
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -732,10 +759,24 @@ impl RpcHandler {
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params
|
||||
.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
|
||||
"sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
|
||||
}))?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let sat_per_vbyte = if let Some(rate) = explicit_rate {
|
||||
rate as u64
|
||||
} else {
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v2/wallet/estimatefee/1"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Cannot estimate the next-block fee")?
|
||||
.error_for_status()
|
||||
.context("Next-block fee estimate rejected")?;
|
||||
let estimate: serde_json::Value = response.json().await?;
|
||||
super::fee_policy::estimated_sat_per_vbyte(&estimate)?
|
||||
};
|
||||
|
||||
info!(
|
||||
total_amount = total_amount,
|
||||
@@ -743,8 +784,6 @@ impl RpcHandler {
|
||||
"Creating PSBT for hardware wallet signing"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": lnd_outputs,
|
||||
@@ -1444,3 +1483,81 @@ mod tests {
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
// LND REST uses decimal strings for int64 fields. Match the complete seller
|
||||
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
|
||||
fn json_u64(value: &serde_json::Value) -> Option<u64> {
|
||||
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
||||
}
|
||||
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
|
||||
let settled = match body.get("state").and_then(|v| v.as_str()) {
|
||||
Some(state) => state == "SETTLED",
|
||||
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
|
||||
};
|
||||
settled
|
||||
&& price > 0
|
||||
&& body
|
||||
.get("amt_paid_sat")
|
||||
.and_then(json_u64)
|
||||
.is_some_and(|paid| paid >= price)
|
||||
}
|
||||
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
|
||||
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
|
||||
return None;
|
||||
}
|
||||
body.get("value").and_then(json_u64).filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod peer_file_invoice_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn settlement_requires_terminal_state_and_full_amount() {
|
||||
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [
|
||||
serde_json::json!(6),
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [serde_json::json!(7), serde_json::json!("8")] {
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
|
||||
7
|
||||
));
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
|
||||
0
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
|
||||
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
|
||||
for value in [
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(0),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
let mut invalid = invoice.clone();
|
||||
invalid["value"] = value;
|
||||
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(job.snapshot().await.done,
|
||||
"A firmware flash is in progress; wait before reconnecting or changing radio settings");
|
||||
}
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
@@ -325,7 +329,16 @@ impl RpcHandler {
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
let flash_jobs = Arc::clone(&self.flash_job);
|
||||
tokio::spawn(async move {
|
||||
// Serialize against flash registration. If a flash started
|
||||
// after this RPC saved settings, its completion applies them.
|
||||
let flash_guard = flash_jobs.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
|
||||
@@ -110,7 +110,8 @@ impl RpcHandler {
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
let flash_guard = self.flash_job.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
@@ -131,6 +132,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
drop(flash_guard);
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,15 @@ impl RpcHandler {
|
||||
match handler.handle_package_install(params).await {
|
||||
Ok(_) => {
|
||||
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
|
||||
// and healthy (see install.rs post-start exit check).
|
||||
// Kick the scanner first so the fresh manifest (with
|
||||
@@ -184,17 +193,20 @@ impl RpcHandler {
|
||||
// phase is cleared (None) so no stale InstallPhase
|
||||
// lingers on the card.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
handler
|
||||
.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -252,6 +264,11 @@ impl RpcHandler {
|
||||
match handler.handle_package_uninstall(params).await {
|
||||
Ok(_) => {
|
||||
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
|
||||
// success. Nothing more to do here.
|
||||
}
|
||||
@@ -330,7 +347,7 @@ impl RpcHandler {
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
Ok(result) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
@@ -345,7 +362,11 @@ impl RpcHandler {
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
|
||||
pre_state.clone().unwrap_or(PackageState::Running)
|
||||
} else {
|
||||
PackageState::Running
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -378,52 +399,56 @@ impl RpcHandler {
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.state = PackageState::Installing;
|
||||
state_manager.update_data(data).await;
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// True when the failed install still has a real footprint: any container
|
||||
@@ -481,20 +506,23 @@ async fn remove_entry_with_notification(
|
||||
id_prefix: &str,
|
||||
message: &str,
|
||||
) {
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
handler.state_manager.update_data(data).await;
|
||||
handler
|
||||
.state_manager
|
||||
.mutate_data(|data| {
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
@@ -504,18 +532,23 @@ async fn flip_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set state unconditionally (no-op if entry no longer exists).
|
||||
@@ -524,13 +557,18 @@ async fn set_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
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
|
||||
@@ -541,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if new_state != PackageState::Running {
|
||||
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
|
||||
|
||||
@@ -985,28 +985,26 @@ pub(super) async fn get_app_config(
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
let storage = crate::container::npm::resolve_storage().await?;
|
||||
let admin_port = allocator
|
||||
.allocate_or_get(app_id, 8081, 81)
|
||||
.await
|
||||
.unwrap_or(8081);
|
||||
let http_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8088, 80)
|
||||
.await
|
||||
.unwrap_or(8084);
|
||||
.unwrap_or(8088);
|
||||
let https_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||
.await
|
||||
.unwrap_or(8444);
|
||||
(
|
||||
vec![
|
||||
format!("{}:81", admin_port),
|
||||
format!("{}:80", http_port),
|
||||
format!("{}:443", https_port),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("127.0.0.1:{}:81", admin_port),
|
||||
format!("127.0.0.1:{}:80", http_port),
|
||||
format!("127.0.0.1:{}:443", https_port),
|
||||
],
|
||||
storage.bind_mounts(),
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -22,6 +22,18 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
|
||||
/// hardcoded id list below — a new app just declares the dependency instead
|
||||
/// of needing a code change here.
|
||||
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() {
|
||||
let path = apps_dir.join(package_id).join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
@@ -1055,6 +1067,14 @@ mod tests {
|
||||
// edit to `requires_unpruned_bitcoin`.
|
||||
assert!(manifest_declares_archival_bitcoin("electrumx"));
|
||||
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.
|
||||
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
|
||||
// An id with no manifest on disk at all.
|
||||
|
||||
@@ -326,6 +326,10 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
@@ -541,7 +545,7 @@ impl RpcHandler {
|
||||
// Keep legacy install flow as default while migration is in progress.
|
||||
if orchestrator_managed {
|
||||
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;
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
||||
@@ -569,6 +573,9 @@ impl RpcHandler {
|
||||
"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) => {
|
||||
info!(
|
||||
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
|
||||
@@ -686,7 +693,11 @@ impl RpcHandler {
|
||||
// These standalone web UIs have repeatedly lost host listeners
|
||||
// under Podman's rootless pasta backend while staying healthy internally.
|
||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
||||
run_args.push("--network=slirp4netns:allow_host_loopback=true");
|
||||
run_args.push(if package_id == "nginx-proxy-manager" {
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
} else {
|
||||
"--network=slirp4netns:allow_host_loopback=true"
|
||||
});
|
||||
} else if needs_archy_net(package_id) {
|
||||
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
||||
match tokio::process::Command::new("podman")
|
||||
@@ -1561,88 +1572,8 @@ autopilot.active=false\n",
|
||||
super::pine_ha::restart_home_assistant_if_running().await;
|
||||
}
|
||||
}
|
||||
if package_id == "filebrowser" {
|
||||
// Generate a random password (32 bytes, hex-encoded)
|
||||
let mut buf = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
let password = hex::encode(buf);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create HTTP client for FileBrowser hook: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry loop: FileBrowser may take time to initialize its SQLite database
|
||||
let mut password_changed = false;
|
||||
for attempt in 0..6u32 {
|
||||
let delay = if attempt == 0 { 5 } else { 10 };
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
|
||||
// Try to log in with default credentials
|
||||
let login_res = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token = match login_res {
|
||||
Ok(resp) if resp.status().is_success() => match resp.text().await {
|
||||
Ok(t) => t.trim_matches('"').to_string(),
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => {
|
||||
debug!("FileBrowser not ready (attempt {}/6)", attempt + 1);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Change admin password
|
||||
let change_res = client
|
||||
.put("http://127.0.0.1:8083/api/users/1")
|
||||
.header("X-Auth", &token)
|
||||
.json(&serde_json::json!({"password": password}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match change_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let secret_dir = "/var/lib/archipelago/secrets/filebrowser";
|
||||
if let Err(e) = tokio::fs::create_dir_all(secret_dir).await {
|
||||
tracing::warn!("Failed to create filebrowser secrets dir: {}", e);
|
||||
}
|
||||
let pw_path = format!("{}/password", secret_dir);
|
||||
if let Err(e) = tokio::fs::write(&pw_path, &password).await {
|
||||
tracing::warn!("Failed to write filebrowser password: {}", e);
|
||||
}
|
||||
// Set restrictive permissions on the password file
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
&pw_path,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
);
|
||||
}
|
||||
info!("FileBrowser admin password secured (default credentials replaced)");
|
||||
password_changed = true;
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
if !password_changed {
|
||||
tracing::warn!(
|
||||
"FileBrowser password could not be changed after 6 attempts — \
|
||||
default credentials (admin/admin) remain active"
|
||||
);
|
||||
}
|
||||
}
|
||||
// File Browser credentials are provisioned and verified before the
|
||||
// server starts. Never attempt a default-password change after launch.
|
||||
|
||||
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
||||
{
|
||||
@@ -1695,32 +1626,10 @@ autopilot.active=false\n",
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
||||
// in a new tab on that direct port so absolute asset URLs must be
|
||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
||||
if package_id == "gitea" {
|
||||
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
|
||||
);
|
||||
}
|
||||
// Gitea owns its public URL and security settings in app.ini, including
|
||||
// values chosen in its first-run setup. Do not rewrite operator values
|
||||
// or claim success from best-effort grep/sed commands. The app gate
|
||||
// fronts its declared HTTP port and handles frame headers separately.
|
||||
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = &self.config.host_ip;
|
||||
@@ -1927,10 +1836,10 @@ autopilot.active=false\n",
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result<serde_json::Value> {
|
||||
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password";
|
||||
let password = tokio::fs::read_to_string(secret_path)
|
||||
.await
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new(
|
||||
"/var/lib/archipelago/secrets/filebrowser",
|
||||
))
|
||||
.await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
@@ -1939,7 +1848,7 @@ autopilot.active=false\n",
|
||||
|
||||
let resp = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": password}))
|
||||
.json(&serde_json::json!({"username": credentials.username, "password": credentials.password}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to FileBrowser")?;
|
||||
@@ -1969,17 +1878,16 @@ autopilot.active=false\n",
|
||||
super::validation::validate_app_id(app_id)?;
|
||||
|
||||
if app_id == "filebrowser" {
|
||||
let password =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
|
||||
.await
|
||||
.map(|p| p.trim().to_string())
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(
|
||||
std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"),
|
||||
)
|
||||
.await?;
|
||||
return Ok(serde_json::json!({
|
||||
"title": "File Browser credentials",
|
||||
"description": "Use these credentials when File Browser asks you to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
{ "label": "Username", "value": credentials.username },
|
||||
{ "label": "Password", "value": credentials.password, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
@@ -2049,25 +1957,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
match package_id {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").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,
|
||||
_ => {}
|
||||
}
|
||||
// Never kill by port: another app or the management gate may own it.
|
||||
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||
}
|
||||
|
||||
fn install_command_tail(
|
||||
@@ -2192,93 +2083,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
match package_id {
|
||||
"grafana"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
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,
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
|
||||
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
false
|
||||
}
|
||||
|
||||
async fn repair_nextcloud_permissions() {
|
||||
|
||||
@@ -14,20 +14,23 @@ impl RpcHandler {
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
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;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||
/// to start…") without disturbing the current phase/byte counters.
|
||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
@@ -114,25 +127,28 @@ impl RpcHandler {
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -1431,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
// 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
|
||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||
// the port cleanup below: killing the owner is what actually frees the
|
||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
||||
// starting the replacement. A port sweep cannot distinguish a ghost
|
||||
// from the dashboard gate or another live app and must never kill it.
|
||||
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) {
|
||||
@@ -1577,42 +1576,110 @@ async fn repair_netbird_network() {
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
// Quadlet owns managed containers; its backed-up reconciliation applies
|
||||
// port and mount changes. Never remove a systemd-owned container here.
|
||||
if crate::container::quadlet::unit_exists("nginx-proxy-manager").await {
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
// Serialize repair so a second caller cannot overlap a replacement.
|
||||
static REPAIR: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
let _repair = REPAIR.lock().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = repair_legacy_nginx_proxy_manager().await {
|
||||
tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved");
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous";
|
||||
|
||||
async fn restore_failed_npm_repair() -> Result<()> {
|
||||
let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
removed.status.success(),
|
||||
"cannot remove failed NPM replacement; previous container retained"
|
||||
);
|
||||
let renamed =
|
||||
podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
renamed.status.success(),
|
||||
"cannot restore previous NPM container name"
|
||||
);
|
||||
let started = podman_control(&["start", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
started.status.success(),
|
||||
"previous NPM container restored but failed to start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_legacy_nginx_proxy_manager() -> Result<()> {
|
||||
let previous = podman_control(&["container", "exists", NPM_PREVIOUS_CONTAINER]).await?;
|
||||
anyhow::ensure!(previous.status.code() == Some(1),
|
||||
"NPM previous-container slot is occupied or cannot be inspected; preserve it and review interrupted repair before proceeding");
|
||||
let inspection = podman_control(&[
|
||||
"inspect",
|
||||
"nginx-proxy-manager",
|
||||
"--format",
|
||||
"{{json .Config.Env}}",
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
inspection.status.success(),
|
||||
"cannot preserve NPM environment before repair"
|
||||
);
|
||||
let environment: Vec<String> =
|
||||
serde_json::from_slice(&inspection.stdout).context("invalid original NPM environment")?;
|
||||
let environment = npm_repair_environment(&environment)?;
|
||||
let storage = crate::container::npm::resolve_storage().await?;
|
||||
let mut manifest: archipelago_container::AppManifest = serde_yaml::from_str(include_str!(
|
||||
"../../../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))?;
|
||||
storage.apply(&mut manifest)?;
|
||||
let stopped = podman_control(&["stop", "--time", "30", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
stopped.status.success(),
|
||||
"could not stop NPM for a consistent backup"
|
||||
);
|
||||
if let Err(error) = crate::container::migration_backup::snapshot(
|
||||
&manifest,
|
||||
std::path::Path::new("/var/lib/archipelago"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = podman_control(&["start", "nginx-proxy-manager"]).await;
|
||||
return Err(error);
|
||||
}
|
||||
// Keep the original runtime definition for rollback, including its operator
|
||||
// options. Never delete it before the replacement has become ready.
|
||||
let renamed = podman_control(&["rename", "nginx-proxy-manager", NPM_PREVIOUS_CONTAINER]).await;
|
||||
if !renamed.as_ref().is_ok_and(|out| out.status.success()) {
|
||||
let _ = podman_control(&["start", "nginx-proxy-manager"]).await;
|
||||
anyhow::bail!("could not retain legacy NPM runtime; state backup preserved, inspect both container names before retrying");
|
||||
}
|
||||
let replacement = async {
|
||||
recreate_nginx_proxy_manager_container(&storage, &environment).await?;
|
||||
anyhow::ensure!(
|
||||
wait_for_runtime_host_port("nginx-proxy-manager", 8081, 180).await,
|
||||
"replacement NPM admin listener did not become ready"
|
||||
);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = replacement {
|
||||
restore_failed_npm_repair()
|
||||
.await
|
||||
.context("restoring previous NPM after replacement failure")?;
|
||||
return Err(error);
|
||||
}
|
||||
let removed = podman_control(&["rm", NPM_PREVIOUS_CONTAINER]).await?;
|
||||
anyhow::ensure!(
|
||||
removed.status.success(),
|
||||
"replacement ready but previous NPM cleanup failed; rollback container retained"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
@@ -1647,36 +1714,75 @@ async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
fn npm_repair_environment(values: &[String]) -> Result<Vec<(String, String)>> {
|
||||
values.iter().map(|value| {
|
||||
let (key, value) = value.split_once('=').context("invalid NPM environment entry")?;
|
||||
anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"unsupported NPM environment name; original container preserved");
|
||||
anyhow::ensure!(!value.contains(['\n', '\r', '\0']),
|
||||
"NPM environment requires explicit migration of a multiline value; original container preserved");
|
||||
Ok((key.to_owned(), value.to_owned()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
struct NpmRepairEnvironmentFile(std::path::PathBuf);
|
||||
|
||||
impl NpmRepairEnvironmentFile {
|
||||
fn create(environment: &[(String, String)]) -> Result<Self> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let path = std::env::temp_dir().join(format!(".archy-npm-env-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)?;
|
||||
let guard = Self(path);
|
||||
for (key, value) in environment {
|
||||
writeln!(file, "{key}={value}")?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NpmRepairEnvironmentFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container(
|
||||
storage: &crate::container::npm::Storage,
|
||||
environment: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Existing directories and ownership came from the active runtime. Never
|
||||
// create a second database tree or recursively rewrite data permissions.
|
||||
for directory in [&storage.data, &storage.certificates] {
|
||||
anyhow::ensure!(
|
||||
std::path::Path::new(directory).is_dir(),
|
||||
"NPM persistent directory is missing"
|
||||
);
|
||||
}
|
||||
|
||||
// This repair changes connectivity, not NPM's application version. Keep
|
||||
// the exact old image so rollback never starts an older binary against a
|
||||
// database that an incidental mutable-tag update may have migrated.
|
||||
let image_output =
|
||||
podman_control(&["inspect", NPM_PREVIOUS_CONTAINER, "--format", "{{.Image}}"]).await?;
|
||||
anyhow::ensure!(
|
||||
image_output.status.success(),
|
||||
"cannot resolve original NPM image for repair"
|
||||
);
|
||||
let image = String::from_utf8(image_output.stdout)?.trim().to_string();
|
||||
anyhow::ensure!(!image.is_empty(), "original NPM image is missing");
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
@@ -1684,24 +1790,32 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"127.0.0.1:8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"127.0.0.1:8088:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"127.0.0.1:8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
format!("{}:/data", storage.data),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("{}:/etc/letsencrypt", storage.certificates),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
// Keep values out of argv/logs AND out of Podman's host environment
|
||||
// (a container's PATH or LD_PRELOAD must never alter the host command).
|
||||
let env_file = NpmRepairEnvironmentFile::create(environment)?;
|
||||
args.extend([
|
||||
"--env-file".to_string(),
|
||||
env_file.0.to_string_lossy().into_owned(),
|
||||
]);
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.args(&args);
|
||||
let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
@@ -1769,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec<u16> {
|
||||
"vaultwarden" => vec![8082],
|
||||
"gitea" => vec![3001, 2222, 3000],
|
||||
"nextcloud" => vec![8085],
|
||||
"nginx-proxy-manager" => vec![8081, 8084, 8444],
|
||||
"nginx-proxy-manager" => vec![8081, 8088, 8444],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ports
|
||||
@@ -1780,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16
|
||||
ports.push(3000);
|
||||
}
|
||||
if container_name == "nginx-proxy-manager" {
|
||||
for port in [8084, 8444] {
|
||||
for port in [8088, 8444] {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
@@ -1812,6 +1926,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
|
||||
|
||||
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||
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") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
}
|
||||
@@ -1842,6 +1959,7 @@ async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_sec
|
||||
loop {
|
||||
let ready = match container_name {
|
||||
"uptime-kuma" => http_host_port_ready(port, "/").await,
|
||||
"nginx-proxy-manager" => http_host_port_ready(port, "/api/").await,
|
||||
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok(),
|
||||
@@ -2032,51 +2150,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ports = runtime_host_ports(container_name);
|
||||
if !ports.is_empty() {
|
||||
cleanup_ports(&ports).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Only reap processes proven to belong to an absent container. The app
|
||||
// gate shares the app's port on other addresses and lives in this daemon;
|
||||
// killing port owners (or matching argv with pkill) kills the dashboard.
|
||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||
@@ -2095,13 +2172,16 @@ async fn flip_package_state(
|
||||
package_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = transitional;
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Write the package entry's final state. No-op if the entry has since
|
||||
@@ -2111,13 +2191,18 @@ async fn set_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
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) {
|
||||
@@ -2183,8 +2268,54 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn npm_environment_backup_is_private_exact_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let values = vec![
|
||||
"DB_PASSWORD=fixture=a b".to_string(),
|
||||
"PATH=/container/only".to_string(),
|
||||
];
|
||||
let parsed = super::npm_repair_environment(&values).unwrap();
|
||||
let host_path = std::env::var_os("PATH");
|
||||
let path = {
|
||||
let file = super::NpmRepairEnvironmentFile::create(&parsed).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&file.0).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file.0).unwrap(),
|
||||
"DB_PASSWORD=fixture=a b\nPATH=/container/only\n"
|
||||
);
|
||||
assert_eq!(std::env::var_os("PATH"), host_path);
|
||||
file.0.clone()
|
||||
};
|
||||
assert!(!path.exists());
|
||||
for value in [
|
||||
"INVALID",
|
||||
"=empty key",
|
||||
"KEY=value\nINJECTED=true",
|
||||
"--env=value",
|
||||
] {
|
||||
assert!(super::npm_repair_environment(&[value.to_string()]).is_err());
|
||||
}
|
||||
}
|
||||
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]
|
||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||
// 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 cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
impl RpcHandler {
|
||||
/// Update a package to the version pinned in image-versions.sh.
|
||||
/// Update a package to the freshly verified catalog target.
|
||||
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||
pub(in crate::api::rpc) async fn handle_package_update(
|
||||
&self,
|
||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// An Update click must not act on an hourly cache that predates the
|
||||
// button. Fetch and verify first; failure leaves running containers alone.
|
||||
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.context(
|
||||
"Cannot check the signed app catalog; update cancelled before changing containers",
|
||||
)?;
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
// Reload even when bytes did not change: a previous reload may have
|
||||
// failed after the cache was written, or another refresher wrote it.
|
||||
orch.reload_manifests()
|
||||
.await
|
||||
.context("Cannot load current app manifests; update cancelled")?;
|
||||
}
|
||||
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
@@ -42,6 +57,22 @@ impl RpcHandler {
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
let targets = pinned
|
||||
.as_ref()
|
||||
.map(|target| self.resolve_images_to_pull(package_id, target));
|
||||
if let Some(targets) = &targets {
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
if !update_targets_need_change(targets, &installed)? {
|
||||
install_log(&format!(
|
||||
"UPDATE SKIP: {} — target versions already installed",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
@@ -80,6 +111,12 @@ impl RpcHandler {
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
if let Some(targets) = &targets {
|
||||
verify_update_targets(
|
||||
targets,
|
||||
&inspect_update_images(package_id).await?,
|
||||
)?;
|
||||
}
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
@@ -133,7 +170,8 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
let images_to_pull =
|
||||
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
warn!(
|
||||
"Update {}: container {} exited after recreate",
|
||||
package_id, name
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
o.status.success() && state == "running",
|
||||
"Update {}: container {} is not running after recreate",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Update {}: cannot inspect recreated container {}",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -514,6 +559,98 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
package_id
|
||||
);
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.arg("inspect").args(&containers).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
.await
|
||||
.context("Timed out checking installed images")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Cannot inspect installed images; update cancelled"
|
||||
);
|
||||
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||
inspected
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let name = entry
|
||||
.get("Name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
|
||||
let image = entry
|
||||
.get("ImageName")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
|
||||
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn installed_image_for_target<'a>(
|
||||
app_id: &str,
|
||||
installed: &'a [(String, String)],
|
||||
) -> Option<&'a str> {
|
||||
installed
|
||||
.iter()
|
||||
.find(|(name, _)| {
|
||||
candidate_app_ids_for_container(name)
|
||||
.iter()
|
||||
.any(|id| id == app_id)
|
||||
})
|
||||
.map(|(_, image)| image.as_str())
|
||||
}
|
||||
|
||||
/// A successful recreate is not proof that it used the downloaded image.
|
||||
fn verify_update_targets(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
|
||||
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
image_versions::extract_version_from_image(target)
|
||||
== image_versions::extract_version_from_image(running)
|
||||
|| image_versions::compare_image_versions(target, running)
|
||||
== Some(std::cmp::Ordering::Equal),
|
||||
"Update {}: recreated container did not reach target version {}",
|
||||
app_id,
|
||||
image_versions::extract_version_from_image(target)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check every stack component, not just the version shown on its tile. A
|
||||
/// newer backend must still update when its frontend version is unchanged.
|
||||
/// A stale target for any component cancels before pulling or stopping anything.
|
||||
fn update_targets_need_change(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<bool> {
|
||||
use std::cmp::Ordering;
|
||||
let mut changed = false;
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed);
|
||||
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
|
||||
Some(Ordering::Less) => anyhow::bail!(
|
||||
"Catalog target for {} is older than the installed image; refusing downgrade",
|
||||
app_id
|
||||
),
|
||||
Some(Ordering::Equal) => {}
|
||||
Some(Ordering::Greater) | None => changed = true,
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
// A primary container already at its target does not mean its backend or
|
||||
// database is current. Route every mapped stack through the component flow.
|
||||
!image_versions::containers_for_stack(package_id).is_empty()
|
||||
|| matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
"mysql-mempool" => push("archy-mempool-db"),
|
||||
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
|
||||
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
|
||||
"fedimintd" => push("fedimint"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let stale = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&stale, &installed).is_err());
|
||||
let current = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &legacy).unwrap());
|
||||
let newer = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&newer, &installed).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_update_checks_backend_even_when_frontend_matches() {
|
||||
let installed = vec![
|
||||
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
|
||||
];
|
||||
let mut targets = vec![
|
||||
(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/team/web:3.3.1-archy1".into(),
|
||||
),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
|
||||
];
|
||||
assert!(update_targets_need_change(&targets, &installed).unwrap());
|
||||
targets[0].1 = "r.test/team/web:3.3.1".into();
|
||||
assert!(update_targets_need_change(&targets, &installed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_completion_requires_the_target_version_to_be_installed() {
|
||||
let targets = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let mut installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(verify_update_targets(&targets, &installed).is_err());
|
||||
assert!(verify_update_targets(&targets, &[]).is_err());
|
||||
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
|
||||
assert!(verify_update_targets(&targets, &installed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
for app in [
|
||||
"immich",
|
||||
"penpot",
|
||||
"indeedhub",
|
||||
"mempool",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
|
||||
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod known_service_tests {
|
||||
use super::{is_protocol_service, known_service_port};
|
||||
|
||||
#[test]
|
||||
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
|
||||
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
|
||||
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
|
||||
// here means auto-enrollment silently skips it (known_service_port
|
||||
// returns 0) and, separately, regenerate_torrc falls back to the
|
||||
// web-app HiddenServicePort-80 default instead of forwarding 8333
|
||||
// straight through.
|
||||
assert_eq!(known_service_port("bitcoin-core"), 8333);
|
||||
assert!(is_protocol_service("bitcoin-core"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod torrc_tests {
|
||||
use super::app_hidden_service_port_line;
|
||||
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
|
||||
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
||||
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
|
||||
app_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = transitional;
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||
/// removed (e.g. uninstall ran concurrently).
|
||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ impl PortMap {
|
||||
/// there.
|
||||
fn apps_dirs() -> Vec<PathBuf> {
|
||||
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") {
|
||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||
}
|
||||
|
||||
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
|
||||
.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.
|
||||
pub async fn run(
|
||||
gate: Arc<AppGate>,
|
||||
@@ -162,11 +190,12 @@ pub async fn run(
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||
}
|
||||
_ = interval.tick() => {}
|
||||
_ = REFRESH_KICK.notified() => {}
|
||||
_ = 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());
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
||||
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Running,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -1069,6 +1069,7 @@ mod tests {
|
||||
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
};
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Running,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
let state = if lower.contains("loading block index") {
|
||||
Some("loading its block index. This can take a while after installation or restart")
|
||||
} else if lower.contains("replaying blocks") {
|
||||
Some("checking saved blocks before startup completes")
|
||||
} else if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -340,3 +344,21 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,119 @@ const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// Finish manifest promotion before constructing the orchestrator or starting
|
||||
/// catalog refresh/reconciliation. Replacing the app tree in the background
|
||||
/// could let a reload observe its temporary empty state and forget disk-only apps.
|
||||
pub async fn ensure_runtime_assets_ready() {
|
||||
// Install the guard before any startup path can reload an older dashboard
|
||||
// vhost. The canonical OTA/ISO config contains the same guard inline.
|
||||
if Path::new(NGINX_CONF_PATH).exists() || Path::new(NGINX_ENABLED_CONF_PATH).exists() {
|
||||
match host_sudo(&[
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.success() => debug!("Dashboard public source guard verified"),
|
||||
Ok(status) => warn!("Dashboard public source guard needs attention: {status}"),
|
||||
Err(error) => warn!("Dashboard public source guard could not run: {error}"),
|
||||
}
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// A binary-only qualification or OTA rollback can precede the matching
|
||||
// script payload. Install the exact embedded helper before Quadlet
|
||||
// reconciliation can introduce its required ExecStartPre command.
|
||||
if let Err(error) = write_root_if_needed(
|
||||
"/opt/archipelago/scripts/filebrowser-credentials.py",
|
||||
include_str!("../../../scripts/filebrowser-credentials.py"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("File Browser credential helper installation failed: {error:#}");
|
||||
}
|
||||
if let Err(error) = run_npm_bridge_bootstrap().await {
|
||||
warn!("NPM public routing bootstrap needs attention: {error:#}");
|
||||
}
|
||||
// Repair the narrowly recognized legacy NPM tunnel override before app
|
||||
// reconciliation. The embedded script ships in both OTA and ISO binaries.
|
||||
// It preserves native wallet services and refuses unknown custom routing.
|
||||
match tokio::process::Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(include_str!("../../../scripts/repair-npm-tunnel.py"))
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
if !output.stdout.is_empty() {
|
||||
info!("{}", String::from_utf8_lossy(&output.stdout).trim());
|
||||
}
|
||||
}
|
||||
Ok(output) => warn!(
|
||||
"NPM tunnel migration needs attention: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
Err(error) => warn!("NPM tunnel migration could not run: {error}"),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_npm_bridge_bootstrap() -> Result<()> {
|
||||
if !Path::new("/opt/archipelago/scripts").is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut units_changed = false;
|
||||
for (path, content) in [
|
||||
(
|
||||
"/opt/archipelago/scripts/npm-public-bridge.py",
|
||||
include_str!("../../../scripts/npm-public-bridge.py"),
|
||||
),
|
||||
(
|
||||
"/opt/archipelago/scripts/dashboard-public-guard.py",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
),
|
||||
(
|
||||
"/etc/systemd/system/archipelago-npm-bridge.service",
|
||||
include_str!("../../../image-recipe/configs/archipelago-npm-bridge.service"),
|
||||
),
|
||||
(
|
||||
"/etc/systemd/system/archipelago-npm-bridge.timer",
|
||||
include_str!("../../../image-recipe/configs/archipelago-npm-bridge.timer"),
|
||||
),
|
||||
] {
|
||||
let changed = write_root_if_needed(path, content).await?;
|
||||
units_changed |= changed && (path.ends_with(".service") || path.ends_with(".timer"));
|
||||
}
|
||||
if units_changed {
|
||||
anyhow::ensure!(
|
||||
host_sudo(&["systemctl", "daemon-reload"]).await?.success(),
|
||||
"NPM bridge daemon reload failed"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
host_sudo(&[
|
||||
"systemctl",
|
||||
"enable",
|
||||
"--now",
|
||||
"archipelago-npm-bridge.timer"
|
||||
])
|
||||
.await?
|
||||
.success(),
|
||||
"NPM bridge timer could not start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -146,11 +259,6 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run().await {
|
||||
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
|
||||
Ok(_) => debug!("Doctor artifacts already in sync"),
|
||||
@@ -168,13 +276,6 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_tor_helper_sync().await {
|
||||
Ok(true) => info!("tor-helper.sh synchronized with binary"),
|
||||
Ok(false) => debug!("tor-helper.sh already current"),
|
||||
@@ -395,17 +496,28 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
"--install",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
.context("install guarded nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
let acme_status = host_sudo(&[
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/npm-public-bridge.py"),
|
||||
"--acme-only",
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
acme_status.success(),
|
||||
"active NPM ACME root migration failed"
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -422,6 +534,8 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
"archipelago-doctor.service",
|
||||
"archipelago-doctor.timer",
|
||||
"archipelago-host-secrets-audit.service",
|
||||
"archipelago-npm-bridge.service",
|
||||
"archipelago-npm-bridge.timer",
|
||||
] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -449,7 +563,7 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
// or directory"). Skipped when byte-identical; a running daemon is
|
||||
// unaffected (install replaces the inode) and picks the new binary up
|
||||
// on its next spawn.
|
||||
for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] {
|
||||
for tool in ["archy-reticulum-daemon", "archy-rnodeconf", "archy-esptool"] {
|
||||
let src = runtime_dir.join("radio-tools").join(tool);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
|
||||
@@ -102,6 +102,33 @@ pub struct AppCatalogEntry {
|
||||
/// `docs/registry-manifest-design.md`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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| {
|
||||
matches!(
|
||||
capability.as_str(),
|
||||
"runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some(variant.manifest);
|
||||
}
|
||||
}
|
||||
entry.manifest
|
||||
}
|
||||
|
||||
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||
@@ -234,7 +261,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
load_catalog()
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||
.filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -400,7 +427,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
return crate::container::image_versions::available_catalog_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
@@ -443,6 +470,12 @@ pub struct CatalogRefresh {
|
||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> {
|
||||
// Explicit operator-only qualification of a signed candidate on selected
|
||||
// nodes. Never change fleet mirrors or fall back to an older public catalog
|
||||
// while a candidate is selected. Normal signature enforcement still applies.
|
||||
if let Some(path) = std::env::var_os("ARCHY_APP_CATALOG_CANDIDATE") {
|
||||
return refresh_candidate_catalog(data_dir, Path::new(&path)).await;
|
||||
}
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -489,6 +522,49 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn refresh_candidate_catalog(data_dir: &Path, path: &Path) -> anyhow::Result<CatalogRefresh> {
|
||||
anyhow::ensure!(
|
||||
path.is_absolute(),
|
||||
"candidate catalog path must be absolute"
|
||||
);
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.context("inspect candidate catalog")?;
|
||||
anyhow::ensure!(
|
||||
metadata.is_file() && metadata.len() <= 4 * 1024 * 1024,
|
||||
"candidate catalog must be a file no larger than 4 MiB"
|
||||
);
|
||||
let body = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.context("read candidate catalog")?;
|
||||
anyhow::ensure!(
|
||||
body.len() <= 4 * 1024 * 1024,
|
||||
"candidate catalog exceeds 4 MiB"
|
||||
);
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
crate::trust::verify_detached(&raw)?,
|
||||
crate::trust::SignatureStatus::Verified { anchored: true, .. }
|
||||
),
|
||||
"candidate catalog requires a signature anchored to the release root"
|
||||
);
|
||||
let catalog: AppCatalog = serde_json::from_value(raw)?;
|
||||
let changed = write_cache(data_dir, &body)?;
|
||||
if changed {
|
||||
*CACHE.lock().unwrap() = None;
|
||||
}
|
||||
info!(
|
||||
apps = catalog.apps.len(),
|
||||
changed,
|
||||
"app-catalog: using explicitly selected signed candidate; public catalog refresh paused"
|
||||
);
|
||||
Ok(CatalogRefresh {
|
||||
apps: catalog.apps.len(),
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -557,6 +633,122 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_candidate(key_byte: u8) -> serde_json::Value {
|
||||
// Same test anchor as trust::signed_doc tests; never a production key.
|
||||
let anchor = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
std::env::set_var(
|
||||
"ARCHY_RELEASE_ROOT_PUBKEY",
|
||||
hex::encode(anchor.verifying_key().to_bytes()),
|
||||
);
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[key_byte; 32]);
|
||||
let mut value = serde_json::json!({"schema":1,"apps":{"demo":{"version":"2"}},"future_field":{"retain":true}});
|
||||
let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &value).unwrap();
|
||||
value["signature"] = sig.into();
|
||||
value["signed_by"] = did.into();
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_catalog_preserves_signed_bytes_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let body = serde_json::to_string_pretty(&signed_candidate(7)).unwrap();
|
||||
std::fs::write(&path, &body).unwrap();
|
||||
let first = refresh_candidate_catalog(dir.path(), &path).await.unwrap();
|
||||
assert!(first.changed);
|
||||
assert_eq!(first.apps, 1);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
body
|
||||
);
|
||||
assert!(
|
||||
!refresh_candidate_catalog(dir.path(), &path)
|
||||
.await
|
||||
.unwrap()
|
||||
.changed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_candidate_never_replaces_previous_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let previous = "previous cached bytes";
|
||||
write_cache(dir.path(), previous).unwrap();
|
||||
let mut tampered = signed_candidate(7);
|
||||
tampered["apps"]["demo"]["version"] = "tampered".into();
|
||||
for body in [
|
||||
"malformed".into(),
|
||||
r#"{"schema":1,"apps":{}}"#.into(),
|
||||
signed_candidate(11).to_string(),
|
||||
tampered.to_string(),
|
||||
" ".repeat(4 * 1024 * 1024 + 1),
|
||||
] {
|
||||
std::fs::write(&path, body).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert!(
|
||||
refresh_candidate_catalog(dir.path(), Path::new("relative.json"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn npm_gateway_variant_requires_explicit_runtime_support() {
|
||||
let mut raw = serde_json::json!({
|
||||
"version": "2.12.1", "manifest": {"network":"pasta"},
|
||||
"manifest_variants": [{"requires":["runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"],
|
||||
"manifest":{"network":"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"}}]
|
||||
});
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw.clone()).unwrap()).unwrap()["network"],
|
||||
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
// A runtime missing any required capability must retain the base.
|
||||
raw["manifest_variants"][0]["requires"][1] = serde_json::json!("unknown-gateway-v2");
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw).unwrap()).unwrap()["network"],
|
||||
"pasta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -103,6 +103,15 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing companion UIs are provisioned here, never by snapshot recovery.
|
||||
/// A stale running-container snapshot must not resurrect an orphaned UI.
|
||||
pub fn is_companion_app(app_id: &str) -> bool {
|
||||
ALL_COMPANIONS
|
||||
.iter()
|
||||
.flat_map(|specs| specs.iter())
|
||||
.any(|spec| spec.image_base == app_id)
|
||||
}
|
||||
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
@@ -313,7 +322,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
@@ -322,11 +331,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
// Older installers and self-update create :local themselves. It
|
||||
// must receive source updates too; treating it as a permanent
|
||||
// manual override silently kept the old LND UI after an OTA.
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
local_image = local_image_compat.clone();
|
||||
}
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// Reuse either local tag only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
@@ -849,20 +860,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||
["latest", "local"]
|
||||
.iter()
|
||||
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||
.find(|image| build_unit(spec, image).render() == unit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||
for tag in ["local", "latest"] {
|
||||
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||
let unit = build_unit(spec, &image).render();
|
||||
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||
}
|
||||
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
assert_eq!(
|
||||
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use archipelago_container::{
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
@@ -15,6 +16,19 @@ use crate::data_model::{
|
||||
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
||||
};
|
||||
|
||||
/// One displayed package for each known container/manifest alias. Keep the
|
||||
/// stopped-app restoration path in agreement with live-container discovery.
|
||||
fn canonical_package_id(name: &str) -> &str {
|
||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||
"immich_server" | "immich-server" => "immich",
|
||||
"immich-postgres" => "immich_postgres",
|
||||
"immich-redis" => "immich_redis",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
"btcpay" | "btcpayserver" => "btcpay-server",
|
||||
name => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DockerPackageScanner {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
}
|
||||
@@ -25,8 +39,15 @@ impl DockerPackageScanner {
|
||||
}
|
||||
|
||||
/// Scan Docker containers and convert to package data
|
||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
||||
let containers = self.runtime.list_containers().await?;
|
||||
pub async fn scan_containers(
|
||||
&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());
|
||||
|
||||
@@ -91,24 +112,8 @@ impl DockerPackageScanner {
|
||||
debug!("Found {} UI containers", ui_containers.len());
|
||||
|
||||
for container in containers {
|
||||
// Extract app ID from container name
|
||||
// Support both archy-* containers (docker-compose) and plain names (manual)
|
||||
let app_id = if container.name.starts_with("archy-") {
|
||||
container
|
||||
.name
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(&container.name)
|
||||
.to_string()
|
||||
} else {
|
||||
// Use the container name as-is for manually started containers
|
||||
container.name.clone()
|
||||
};
|
||||
|
||||
// Normalize multi-container app IDs to their canonical names
|
||||
let app_id = match app_id.as_str() {
|
||||
"immich_server" => "immich".to_string(),
|
||||
_ => app_id,
|
||||
};
|
||||
// Use the same alias mapping as stopped-app restoration.
|
||||
let app_id = canonical_package_id(&container.name).to_owned();
|
||||
|
||||
// Skip backend services (databases, APIs, etc.)
|
||||
if excluded_services.contains(&app_id.as_str()) {
|
||||
@@ -139,6 +144,18 @@ impl DockerPackageScanner {
|
||||
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
|
||||
let metadata = get_app_metadata(&app_id);
|
||||
// Manifest-owned metadata (icon) wins over the static table: the
|
||||
@@ -158,13 +175,11 @@ impl DockerPackageScanner {
|
||||
} else {
|
||||
// Prefer the known web UI port over arbitrary first binding
|
||||
// (for example Gitea exposes SSH on 2222 before web on 3001).
|
||||
let candidate = if uses_allocated_launch_port(&app_id) {
|
||||
extract_lan_address(&container.ports)
|
||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||
} else {
|
||||
PodmanClient::lan_address_for(&app_id)
|
||||
.or_else(|| extract_lan_address(&container.ports))
|
||||
};
|
||||
let candidate = package_launch_candidate(
|
||||
&app_id,
|
||||
&container.ports,
|
||||
PodmanClient::lan_address_for(&app_id),
|
||||
);
|
||||
reachable_lan_address(&app_id, candidate).await
|
||||
};
|
||||
|
||||
@@ -179,14 +194,22 @@ impl DockerPackageScanner {
|
||||
let tor_address = read_tor_address(&app_id).await;
|
||||
|
||||
// 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,
|
||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||
let available_update =
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
||||
let available_update = if container.id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
|
||||
};
|
||||
|
||||
let package = PackageDataEntry {
|
||||
ui_ready: Some(false),
|
||||
state: package_state.clone(),
|
||||
health: container.health.clone(),
|
||||
exit_code: if package_state == PackageState::Exited {
|
||||
@@ -283,10 +306,259 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
) {
|
||||
let mut present: std::collections::HashSet<String> = containers
|
||||
.iter()
|
||||
.map(|c| canonical_package_id(&c.name).to_owned())
|
||||
.collect();
|
||||
let removed: std::collections::HashSet<_> = uninstalled
|
||||
.iter()
|
||||
.map(|id| canonical_package_id(id))
|
||||
.collect();
|
||||
for name in installed {
|
||||
let id = canonical_package_id(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 btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
||||
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
||||
assert_eq!(canonical_package_id(name), "btcpay-server");
|
||||
}
|
||||
assert_eq!(canonical_package_id("archy-btcpay-db"), "btcpay-db");
|
||||
assert_eq!(canonical_package_id("archy-nbxplorer"), "nbxplorer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
||||
for id in [
|
||||
"immich-postgres",
|
||||
"immich_postgres",
|
||||
"archy-immich-postgres",
|
||||
] {
|
||||
assert_eq!(canonical_package_id(id), "immich_postgres");
|
||||
}
|
||||
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
|
||||
assert_eq!(canonical_package_id("immich-server"), "immich");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mempool_frontend_inventory_alias_does_not_create_a_second_package() {
|
||||
let installed = ["mempool", "archy-mempool-web", "mempool-web"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut containers = Vec::new();
|
||||
restore_absent_installed(&mut containers, &installed, &Default::default());
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].name, "mempool");
|
||||
containers[0].id = "live-frontend".into();
|
||||
containers[0].state = ContainerState::Running;
|
||||
restore_absent_installed(&mut containers, &installed, &Default::default());
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].id, "live-frontend");
|
||||
assert_eq!(containers[0].state, ContainerState::Running);
|
||||
assert_eq!(canonical_package_id("archy-mempool-web"), "mempool");
|
||||
assert_eq!(canonical_package_id("mempool-api"), "mempool-api");
|
||||
containers.clear();
|
||||
restore_absent_installed(
|
||||
&mut containers,
|
||||
&installed,
|
||||
&["mempool".into()].into_iter().collect(),
|
||||
);
|
||||
assert!(containers.is_empty());
|
||||
}
|
||||
|
||||
#[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 {
|
||||
title: String,
|
||||
description: String,
|
||||
@@ -657,9 +929,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
///
|
||||
/// This must match what enrollment actually names the hidden service dir
|
||||
/// with — both the install-time auto-enroll (`install.rs`) and the manual
|
||||
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
|
||||
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
|
||||
/// gets its own identity arm rather than folding into the "bitcoin" alias:
|
||||
/// aliasing it here without also canonicalizing the write side would point
|
||||
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
|
||||
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
|
||||
fn tor_service_name(app_id: &str) -> Option<&'static str> {
|
||||
match app_id {
|
||||
"archipelago" => Some("archipelago"),
|
||||
"bitcoin-core" => Some("bitcoin-core"),
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
|
||||
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
|
||||
"lnd" | "lnd-ui" => Some("lnd"),
|
||||
@@ -721,14 +1003,20 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
let mut first_candidate = None;
|
||||
for port_str in ports {
|
||||
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
||||
let Some(public_part) = port_str.split("->").next() else {
|
||||
let Some((public_part, _)) = port_str.split_once("->") else {
|
||||
continue;
|
||||
};
|
||||
let Some(port_part) = public_part.split(':').nth(1) else {
|
||||
let Some((_, port_part)) = public_part.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
||||
let host_port = port_part.split('-').next().unwrap_or(port_part);
|
||||
let Ok(host_port) = host_port.parse::<u16>() else {
|
||||
continue;
|
||||
};
|
||||
if host_port == 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = format!("http://localhost:{}", host_port);
|
||||
if first_candidate.is_none() {
|
||||
first_candidate = Some(candidate.clone());
|
||||
@@ -846,6 +1134,46 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Companion dashboards remain usable while their backend is syncing. Never
|
||||
/// probe a Bitcoin RPC or Electrum protocol socket as dashboard readiness.
|
||||
fn package_launch_candidate(
|
||||
app_id: &str,
|
||||
ports: &[String],
|
||||
known: Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(companion) = companion_lan_address(app_id) {
|
||||
return Some(companion);
|
||||
}
|
||||
if app_id == "nginx-proxy-manager" {
|
||||
// 80/443 serve users' proxy hosts; only container port 81 serves the
|
||||
// admin UI. Podman's binding order is unstable across recreation.
|
||||
// Resolve its actual host allocation rather than guessing the first
|
||||
// HTTP port or hardcoding the default host port 8081.
|
||||
let admin_ports: Vec<String> = ports
|
||||
.iter()
|
||||
.filter(|port| {
|
||||
port.split_once("->")
|
||||
.is_some_and(|(_, target)| target == "81/tcp")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// With published bindings, a missing admin mapping is not evidence
|
||||
// that some unrelated service on the default host port is this UI.
|
||||
return extract_lan_address(&admin_ports).or_else(|| {
|
||||
if ports.is_empty() {
|
||||
known
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
if uses_allocated_launch_port(app_id) {
|
||||
extract_lan_address(ports).or(known)
|
||||
} else {
|
||||
known.or_else(|| extract_lan_address(ports))
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_allocated_launch_port(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
@@ -906,9 +1234,158 @@ mod launch_url_port_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tor_service_name_tests {
|
||||
use super::tor_service_name;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
|
||||
// Regression: enrollment (install.rs, tor.create-service) writes
|
||||
// HiddenServiceDir/tor-hostnames entries using the raw package_id
|
||||
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
|
||||
// shared "bitcoin" name here would point reads at a directory
|
||||
// enrollment never creates.
|
||||
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
|
||||
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
|
||||
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
|
||||
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extract_lan_address_tests {
|
||||
use super::extract_lan_address;
|
||||
use super::{extract_lan_address, package_launch_candidate};
|
||||
|
||||
#[test]
|
||||
fn companion_dashboard_wins_over_backend_protocol_ports() {
|
||||
for id in ["bitcoin", "bitcoin-core", "bitcoin-knots"] {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
id,
|
||||
&["127.0.0.1:8332->8332/tcp".into()],
|
||||
Some("http://localhost:8332".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8334")
|
||||
);
|
||||
}
|
||||
for id in ["electrumx", "electrs", "mempool-electrs"] {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
id,
|
||||
&["127.0.0.1:50001->50001/tcp".into()],
|
||||
Some("http://localhost:50001".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:50002")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"filebrowser",
|
||||
&["127.0.0.1:19080->80/tcp".into()],
|
||||
Some("http://localhost:8080".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:19080")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_admin_launch_is_independent_of_proxy_binding_order() {
|
||||
let mappings = [
|
||||
"10.77.0.2:18081->80/tcp",
|
||||
"10.77.0.2:18443->443/tcp",
|
||||
"127.0.0.1:8081->81/tcp",
|
||||
];
|
||||
for order in [
|
||||
[0, 1, 2],
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[1, 2, 0],
|
||||
[2, 0, 1],
|
||||
[2, 1, 0],
|
||||
] {
|
||||
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&ports,
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8081")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
|
||||
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
|
||||
let ports = vec![
|
||||
"10.77.0.2:18081->80/tcp".into(),
|
||||
format!("{binding}:28081->81/tcp"),
|
||||
];
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&ports,
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:28081")
|
||||
);
|
||||
}
|
||||
let proxies = vec![
|
||||
"10.77.0.2:18081->80/tcp".into(),
|
||||
"10.77.0.2:18443->443/tcp".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
package_launch_candidate("nginx-proxy-manager", &proxies, None),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&proxies,
|
||||
Some("http://localhost:8081/".into())
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_without_port_information_uses_declared_admin_url() {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&[],
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8081/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_published_ports_do_not_become_launch_urls() {
|
||||
for port in [
|
||||
"81/tcp",
|
||||
"127.0.0.1:bad->81/tcp",
|
||||
"[::1]:0->81/tcp",
|
||||
"[::]:65536->81/tcp",
|
||||
"127.0.0.1:8081->81/udp",
|
||||
] {
|
||||
assert_eq!(
|
||||
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_ssh_port_when_web_port_is_published() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi
|
||||
const DEFAULT_CONFIG_JSON: &str =
|
||||
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
|
||||
|
||||
/// One atomically published record shared by setup, Cloud and credentials UI.
|
||||
/// Deliberately has no Debug implementation: the password must never be logged.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CloudCredentials {
|
||||
pub schema: u32,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn cloud_credentials(directory: &Path) -> Result<CloudCredentials> {
|
||||
let path = directory.join("credentials.json");
|
||||
match fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let value: CloudCredentials = serde_json::from_slice(&bytes)
|
||||
.context("Invalid private File Browser credential record")?;
|
||||
let suffix = value.username.strip_prefix("archy-").unwrap_or("");
|
||||
anyhow::ensure!(
|
||||
value.schema == 1
|
||||
&& suffix.len() == 32
|
||||
&& suffix.bytes().all(|c| c.is_ascii_hexdigit())
|
||||
&& value.password.len() == 64
|
||||
&& value.password.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid managed File Browser credentials"
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Compatibility during staged upgrades only. Never invent admin/admin
|
||||
// when a secret is missing; the pre-start provisioner repairs legacy DBs.
|
||||
let password = fs::read_to_string(directory.join("password"))
|
||||
.await
|
||||
.context("File Browser secure Cloud login has not been provisioned")?;
|
||||
let password = password.trim().to_owned();
|
||||
anyhow::ensure!(
|
||||
!password.is_empty() && password != "admin",
|
||||
"File Browser default credentials must be migrated before Cloud login"
|
||||
);
|
||||
Ok(CloudCredentials {
|
||||
schema: 0,
|
||||
username: "admin".into(),
|
||||
password,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error).context("Cannot read private File Browser credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a stopped server using the same helper used by Quadlet and the ISO.
|
||||
pub async fn prepare_credentials(
|
||||
paths: &EnsurePaths,
|
||||
secret_dir: &Path,
|
||||
image: &str,
|
||||
runtime: &str,
|
||||
) -> Result<()> {
|
||||
let output = tokio::process::Command::new("python3")
|
||||
.args([
|
||||
"-c",
|
||||
include_str!("../../../../scripts/filebrowser-credentials.py"),
|
||||
"--image",
|
||||
image,
|
||||
"--runtime",
|
||||
runtime,
|
||||
"--data-dir",
|
||||
&paths.data_dir.to_string_lossy(),
|
||||
"--srv-root",
|
||||
&paths.srv_root.to_string_lossy(),
|
||||
"--secrets-dir",
|
||||
&secret_dir.to_string_lossy(),
|
||||
])
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.context("Running File Browser credential setup")?;
|
||||
anyhow::ensure!(output.status.success(),
|
||||
"File Browser secure login setup failed; existing state and private rollback backup retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub srv_root: PathBuf,
|
||||
@@ -82,7 +160,18 @@ async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> {
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let tmp = paths.config_path.with_extension("tmp");
|
||||
match fs::write(&tmp, DEFAULT_CONFIG_JSON).await {
|
||||
let legacy = paths.data_dir.join("database.db").exists();
|
||||
let canonical = paths.data_dir.join("filebrowser.db").exists();
|
||||
anyhow::ensure!(
|
||||
!(legacy && canonical),
|
||||
"Multiple File Browser databases need explicit config selection"
|
||||
);
|
||||
let config = if legacy {
|
||||
DEFAULT_CONFIG_JSON.replace("/data/filebrowser.db", "/data/database.db")
|
||||
} else {
|
||||
DEFAULT_CONFIG_JSON.to_string()
|
||||
};
|
||||
match fs::write(&tmp, &config).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.config_path)
|
||||
.await
|
||||
@@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
|
||||
shell_quote(&paths.config_path.to_string_lossy()),
|
||||
DEFAULT_CONFIG_JSON
|
||||
config
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
@@ -117,10 +206,257 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save a complete purchase without overwriting any existing directory entry.
|
||||
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
fn validate_filename(name: &str) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
&& name.len() <= 255,
|
||||
"Invalid purchased filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
validate_filename(name)?;
|
||||
// Never follow a user-created destination directory symlink.
|
||||
match fs::symlink_metadata(dir).await {
|
||||
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
save_after_direct_result(
|
||||
write_direct(dir, name, bytes).await,
|
||||
dir,
|
||||
name,
|
||||
bytes,
|
||||
fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_after_direct_result<F, Fut>(
|
||||
result: std::io::Result<PathBuf>,
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
match result {
|
||||
Ok(path) => Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||
.await
|
||||
.context("Saving purchase in Files user namespace")
|
||||
}
|
||||
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||
if attempt == 1 {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||
_ => format!("{name} ({attempt})"),
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)
|
||||
.await?;
|
||||
let temp = PendingFile(temp_path);
|
||||
file.write_all(bytes).await?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||
.await?;
|
||||
file.sync_all().await?;
|
||||
for attempt in 1..=100 {
|
||||
let target = dir.join(numbered_name(name, attempt));
|
||||
match fs::hard_link(&temp.0, &target).await {
|
||||
Ok(()) => return Ok(target),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Too many existing copies; purchase cache retained",
|
||||
))
|
||||
}
|
||||
|
||||
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||
dir=$1
|
||||
name=$2
|
||||
expected=$3
|
||||
[ ! -L "$dir" ] || exit 1
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp"
|
||||
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
sync -f -- "$tmp"
|
||||
stem=$name
|
||||
ext=
|
||||
case "$name" in
|
||||
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||
esac
|
||||
n=1
|
||||
while [ "$n" -le 100 ]; do
|
||||
candidate=$name
|
||||
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||
dst="$dir/$candidate"
|
||||
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||
printf '%s' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(&dir)
|
||||
.arg(&name)
|
||||
.arg(bytes.len().to_string())
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Starting Files namespace writer")?;
|
||||
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||
let operation = async {
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace writer failed: {}",
|
||||
output.status
|
||||
);
|
||||
fed.context("Sending purchase bytes to Files")?;
|
||||
let chosen =
|
||||
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||
validate_filename(&chosen)?;
|
||||
anyhow::ensure!(
|
||||
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||
"Files writer returned an unexpected name"
|
||||
);
|
||||
Ok(dir.join(chosen))
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||
.await
|
||||
.context("Files namespace writer timed out")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_credentials_use_unique_record_and_never_default_password() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "admin")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "legacy-unique-password")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cloud_credentials(dir.path()).await.unwrap().schema, 0);
|
||||
let value = serde_json::json!({"schema":1,"username":format!("archy-{}", "a".repeat(32)),"password":"b".repeat(64)});
|
||||
fs::write(dir.path().join("credentials.json"), value.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = cloud_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.username, value["username"].as_str().unwrap());
|
||||
assert_eq!(loaded.password, value["password"].as_str().unwrap());
|
||||
fs::write(dir.path().join("credentials.json"), "{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
cloud_credentials(dir.path()).await.is_err(),
|
||||
"damaged managed record must not fall back to old credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_config_preserves_legacy_database_and_refuses_ambiguity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
srv_root: tmp.path().join("srv"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
config_path: tmp.path().join("data/.filebrowser.json"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(paths.data_dir.join("database.db"), b"legacy fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_config(&paths).await.unwrap();
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&paths.config_path).await.unwrap()).unwrap();
|
||||
assert_eq!(config["database"], "/data/database.db");
|
||||
fs::remove_file(&paths.config_path).await.unwrap();
|
||||
fs::write(paths.data_dir.join("filebrowser.db"), b"other fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ensure_config(&paths).await.is_err());
|
||||
assert!(!paths.config_path.exists());
|
||||
assert_eq!(
|
||||
fs::read(paths.data_dir.join("database.db")).await.unwrap(),
|
||||
b"legacy fixture"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_creates_dirs_and_file() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
@@ -152,3 +488,231 @@ mod tests {
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purchase_write_tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
};
|
||||
|
||||
fn no_temps(dir: &Path) {
|
||||
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".archy-saving")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("song.mp3"), b"original")
|
||||
.await
|
||||
.unwrap();
|
||||
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||
b"original"
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..24u8 {
|
||||
let dir = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let bytes = vec![n; 32768];
|
||||
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||
path
|
||||
}));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for task in tasks {
|
||||
assert!(paths.insert(task.await.unwrap()));
|
||||
}
|
||||
assert_eq!(paths.len(), 24);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||
assert!(dir.path().join("name").is_dir());
|
||||
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
] {
|
||||
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||
}
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for n in 1..=100 {
|
||||
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||
for n in 1..=100 {
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
}
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|dir, name, bytes| async move {
|
||||
assert_eq!(bytes, b"abc");
|
||||
Ok(dir.join(name))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, dir.path().join("a"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("namespace"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::StorageFull.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
expected: usize,
|
||||
) -> std::process::Output {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(dir)
|
||||
.arg(name)
|
||||
.arg(expected.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(bytes).await.unwrap();
|
||||
drop(input);
|
||||
child.wait_with_output().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("Music");
|
||||
let name = "song ' $() ; #.mp3";
|
||||
for n in 1..=2 {
|
||||
let output = run_script(&folder, name, b"abc", 3).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(chosen, numbered_name(name, n));
|
||||
let path = folder.join(chosen);
|
||||
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||
assert_eq!(
|
||||
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
no_temps(&folder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(!dir.path().join("never.bin").exists());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(output.stdout, b"name (3)");
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||
0
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_keep_extensions_and_dotfiles() {
|
||||
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
// Read a self-default assignment without evaluating shell code.
|
||||
let default_prefix = format!("${{{key}:-");
|
||||
let val = val
|
||||
.strip_prefix(&default_prefix)
|
||||
.and_then(|v| v.strip_suffix('}'))
|
||||
.unwrap_or(val);
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
|
||||
return None;
|
||||
}
|
||||
available_catalog_update_for_images(pinned, running_image)
|
||||
}
|
||||
|
||||
/// A signed catalog binds the image to an app id, so a publisher namespace
|
||||
/// migration must not hide a real upgrade. Baseline pins still require the
|
||||
/// same repository via `available_update_for_images` above.
|
||||
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let running_version = extract_version_from_image(running_image);
|
||||
if pinned_version == running_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pinned_repo = image_without_registry_or_tag(&pinned);
|
||||
let running_repo = image_without_registry_or_tag(running_image);
|
||||
if pinned_repo != running_repo {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Never advertise a LOWER version as an update.
|
||||
//
|
||||
// Everything upstream of here is a version claim that can go stale: the
|
||||
// signed catalog, a legacy catalog entry with no manifest, the
|
||||
// image-versions.sh baseline pin. When one lags behind what a node is
|
||||
// actually running, a bare `pinned != running` check turns that staleness
|
||||
// into an "Update" button that rolls the node BACKWARDS — and a rollback
|
||||
// to a version withdrawn for a vulnerability is precisely the case where
|
||||
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
|
||||
// 2.3.9 pin, and the UI offering "update" to the exploited release.
|
||||
//
|
||||
// Only suppress when both tags parse as comparable version numbers, so
|
||||
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
|
||||
// keep the previous behaviour rather than silently losing updates.
|
||||
if let (Some(p), Some(r)) = (
|
||||
parse_version_parts(&pinned_version),
|
||||
parse_version_parts(&running_version),
|
||||
if matches!(
|
||||
compare_image_versions(pinned, running_image),
|
||||
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||
) {
|
||||
if p < r {
|
||||
return None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(pinned_version)
|
||||
}
|
||||
|
||||
/// Compare explicit image tags, ignoring registry and namespace. `None` means
|
||||
/// unknown ordering (including floating tags), never permission to downgrade.
|
||||
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
|
||||
/// release, not a SemVer prerelease below it.
|
||||
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
|
||||
use std::cmp::Ordering;
|
||||
let target = extract_version_from_image(target);
|
||||
let running = extract_version_from_image(running);
|
||||
if is_floating_tag(&target) || is_floating_tag(&running) {
|
||||
return None;
|
||||
}
|
||||
let target = target.strip_prefix('v').unwrap_or(&target);
|
||||
let running = running.strip_prefix('v').unwrap_or(&running);
|
||||
if target == running {
|
||||
return Some(Ordering::Equal);
|
||||
}
|
||||
let mut target_core = parse_version_parts(target)?;
|
||||
let mut running_core = parse_version_parts(running)?;
|
||||
while target_core.last() == Some(&0) {
|
||||
target_core.pop();
|
||||
}
|
||||
while running_core.last() == Some(&0) {
|
||||
running_core.pop();
|
||||
}
|
||||
match target_core.cmp(&running_core) {
|
||||
Ordering::Equal => {
|
||||
fn patch_revision(tag: &str) -> Option<u64> {
|
||||
if let Some((base, revision)) = tag.rsplit_once("-archy") {
|
||||
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return revision.parse().ok();
|
||||
}
|
||||
}
|
||||
tag.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.')
|
||||
.then_some(0)
|
||||
}
|
||||
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
|
||||
}
|
||||
order => Some(order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric components of a version tag, for ordering comparisons only.
|
||||
///
|
||||
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
||||
@@ -423,6 +452,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
|
||||
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
|
||||
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
|
||||
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
|
||||
assert_eq!(available_update_for_images(upstream, patch1), None);
|
||||
assert_eq!(available_update_for_images(patch1, patch2), None);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch1, upstream),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch2, patch1),
|
||||
Some("v3.3.1-archy2".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
|
||||
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
|
||||
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(patched, old),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(
|
||||
patched,
|
||||
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(available_update_for_images(patched, old), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_version_spelling_does_not_offer_update() {
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
|
||||
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
|
||||
assert_eq!(
|
||||
images["MEMPOOL_WEB_IMAGE"],
|
||||
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
);
|
||||
assert_eq!(
|
||||
images["MEMPOOL_BACKEND_IMAGE"],
|
||||
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
||||
);
|
||||
assert!(images
|
||||
.values()
|
||||
.all(|v| !v.contains('$') && !v.contains('}')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_var_mapping() {
|
||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||
|
||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
||||
///
|
||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
||||
/// still wants. Rotation calls this instead, so there is no code path from
|
||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
||||
/// as an error the caller reports, never as a wipe.
|
||||
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
match unlock_existing_wallet().await? {
|
||||
true => Ok(()),
|
||||
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,3 +1152,44 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod migration_backup;
|
||||
pub mod npm;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Preserve NPM's active persistent mounts across installer and runtime paths.
|
||||
use anyhow::{bail, Context, Result};
|
||||
use archipelago_container::AppManifest;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Storage {
|
||||
pub data: String,
|
||||
pub certificates: String,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn bind_mounts(&self) -> Vec<String> {
|
||||
vec![
|
||||
format!("{}:/data", self.data),
|
||||
format!("{}:/etc/letsencrypt", self.certificates),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn apply(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
for (target, source) in [
|
||||
("/data", &self.data),
|
||||
("/etc/letsencrypt", &self.certificates),
|
||||
] {
|
||||
let matching: Vec<_> = manifest
|
||||
.app
|
||||
.volumes
|
||||
.iter_mut()
|
||||
.filter(|volume| volume.target == target)
|
||||
.collect();
|
||||
if matching.len() != 1 {
|
||||
bail!("NPM requires one persistent mount per storage target");
|
||||
}
|
||||
let volume = matching.into_iter().next().unwrap();
|
||||
if volume.volume_type != "bind" {
|
||||
bail!("NPM persistent state requires bind mounts");
|
||||
}
|
||||
volume.source.clone_from(source);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_storage(bytes: &[u8]) -> Result<Storage> {
|
||||
let storage: Storage =
|
||||
serde_json::from_slice(bytes).context("invalid NPM storage resolution")?;
|
||||
for value in [&storage.data, &storage.certificates] {
|
||||
if !Path::new(value).is_absolute() || value.chars().any(|c| c.is_control() || c == ':') {
|
||||
bail!("invalid NPM persistent storage path");
|
||||
}
|
||||
}
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
pub async fn resolve_storage() -> Result<Storage> {
|
||||
// Verify live mounts/database before any caller can stop or recreate NPM.
|
||||
// Remember only validated mount paths so later recreation, after the inspect
|
||||
// record is removed, still uses the operator's original storage.
|
||||
let mut command = tokio::process::Command::new("python3");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
include_str!("../../../../scripts/npm-public-bridge.py"),
|
||||
"--resolve",
|
||||
"--remember",
|
||||
"--prepare-realip",
|
||||
])
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(Duration::from_secs(75), command.output())
|
||||
.await
|
||||
.context("NPM storage resolution timed out; existing state preserved")??;
|
||||
if !output.status.success() {
|
||||
bail!("NPM storage resolution failed; inspect mount/database ambiguity or permissions before migration");
|
||||
}
|
||||
parse_storage(&output.stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolved_mounts_preserve_custom_and_legacy_paths() {
|
||||
for data in [
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data",
|
||||
"/srv/operator npm",
|
||||
] {
|
||||
let bytes = serde_json::to_vec(
|
||||
&serde_json::json!({"data": data, "certificates": "/srv/certificates"}),
|
||||
)
|
||||
.unwrap();
|
||||
let storage = parse_storage(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
storage.bind_mounts(),
|
||||
[
|
||||
format!("{data}:/data"),
|
||||
"/srv/certificates:/etc/letsencrypt".into(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_resolution_cannot_become_a_container_mount() {
|
||||
for data in ["relative", "/srv/data:ro", "/srv/data\nother"] {
|
||||
let bytes =
|
||||
serde_json::to_vec(&serde_json::json!({"data": data, "certificates": "/certs"}))
|
||||
.unwrap();
|
||||
assert!(parse_storage(&bytes).is_err());
|
||||
}
|
||||
assert!(parse_storage(b"{}").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,10 @@ pub enum NetworkMode {
|
||||
/// Rootless slirp4netns networking. Podman rejects network aliases with
|
||||
/// this mode, so render only Network=slirp4netns.
|
||||
Slirp4netns,
|
||||
/// Permit explicit host aliases as well as LAN upstreams for NPM.
|
||||
Slirp4netnsHostLoopback,
|
||||
/// Preserve existing NPM upstreams using pasta's former host gateway.
|
||||
Slirp4netnsLegacyGateway,
|
||||
/// Rootless pasta networking. This is more reliable than slirp4netns for
|
||||
/// host port forwarding on long-running web apps.
|
||||
Pasta,
|
||||
@@ -184,6 +188,7 @@ pub struct QuadletUnit {
|
||||
pub no_new_privileges: bool,
|
||||
pub cpu_quota: Option<u32>,
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub stop_grace_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
@@ -216,6 +221,10 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "[Container]");
|
||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||
let _ = writeln!(s, "Image={}", self.image);
|
||||
let grace = self
|
||||
.stop_grace_secs
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||
let _ = writeln!(s, "StopTimeout={grace}");
|
||||
// Pull=never: companions are pre-pulled or built. A missing image
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
@@ -224,6 +233,15 @@ impl QuadletUnit {
|
||||
NetworkMode::Host => {
|
||||
let _ = writeln!(s, "Network=host");
|
||||
}
|
||||
NetworkMode::Slirp4netnsHostLoopback => {
|
||||
let _ = writeln!(s, "Network=slirp4netns:allow_host_loopback=true");
|
||||
}
|
||||
NetworkMode::Slirp4netnsLegacyGateway => {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
}
|
||||
NetworkMode::Slirp4netns => {
|
||||
let _ = writeln!(s, "Network=slirp4netns");
|
||||
}
|
||||
@@ -343,6 +361,26 @@ impl QuadletUnit {
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "[Service]");
|
||||
if self.name == "filebrowser" {
|
||||
// Runs while the managed server is stopped, before it can expose
|
||||
// a default account. The helper verifies real login and root access.
|
||||
let mut argv = vec![
|
||||
"/usr/bin/python3".to_string(),
|
||||
"/opt/archipelago/scripts/filebrowser-credentials.py".to_string(),
|
||||
"--image".to_string(),
|
||||
self.image.clone(),
|
||||
];
|
||||
for (target, flag) in [("/data", "--data-dir"), ("/srv", "--srv-root")] {
|
||||
if let Some(mount) = self
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.find(|m| m.container == Path::new(target))
|
||||
{
|
||||
argv.extend([flag.to_string(), mount.host.display().to_string()]);
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, "ExecStartPre={}", shell_join(&argv));
|
||||
}
|
||||
// Dependency-gated apps may legitimately keep their container entrypoint
|
||||
// in a wait loop before the actual daemon binds ports. Fedimint waits
|
||||
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
|
||||
@@ -350,6 +388,15 @@ impl QuadletUnit {
|
||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||
// not a systemd start gate.
|
||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||
// existing container may still carry Podman's old 10-second default;
|
||||
// StopTimeout alone only protects containers created after migration.
|
||||
let _ = writeln!(s, "ExecStop=");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||
);
|
||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||
// from saturating the journal. Companions: Always. Backends:
|
||||
// OnFailure (clean stops stay stopped).
|
||||
@@ -376,7 +423,10 @@ fn shell_join(parts: &[String]) -> String {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
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
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
@@ -396,7 +446,7 @@ fn quote_environment(env: &str) -> String {
|
||||
if env.is_empty()
|
||||
|| env
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
|
||||
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
|
||||
{
|
||||
let escaped = env
|
||||
.replace('\\', "\\\\")
|
||||
@@ -430,6 +480,12 @@ impl QuadletUnit {
|
||||
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
||||
_ => match app.container.network.as_deref() {
|
||||
Some("slirp4netns") => NetworkMode::Slirp4netns,
|
||||
Some("slirp4netns:allow_host_loopback=true") => {
|
||||
NetworkMode::Slirp4netnsHostLoopback
|
||||
}
|
||||
Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24") => {
|
||||
NetworkMode::Slirp4netnsLegacyGateway
|
||||
}
|
||||
Some("pasta") => NetworkMode::Pasta,
|
||||
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
||||
_ => NetworkMode::Default,
|
||||
@@ -525,6 +581,9 @@ impl QuadletUnit {
|
||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||
restart_policy: RestartPolicy::Always,
|
||||
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||
manifest, name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,6 +735,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
return Ok(TEST_UNITS
|
||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||
.clone());
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||
@@ -686,29 +752,76 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Atomically write `unit` into `dir/<name>.container` if the bytes
|
||||
/// differ from what's already there. Returns true if the file changed.
|
||||
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||
/// the manifest supplies slirp, the two Network= entries are additive and
|
||||
/// Podman rejects startup. Retire only that exact redundant managed override;
|
||||
/// arbitrary operator settings must survive reconciliation.
|
||||
pub async fn redundant_managed_network_override(
|
||||
unit: &QuadletUnit,
|
||||
dir: &Path,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
|
||||
let body = match fs::read_to_string(&path).await {
|
||||
Ok(body) => body,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).context("read managed Portainer network override"),
|
||||
};
|
||||
let lines: Vec<&str> = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect();
|
||||
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
|
||||
}
|
||||
|
||||
async fn retire_managed_network_override(path: &Path) -> Result<()> {
|
||||
let backup = path.with_extension("conf.retired");
|
||||
match fs::hard_link(path, &backup).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
anyhow::ensure!(
|
||||
fs::read(path).await? == fs::read(&backup).await?,
|
||||
"Existing Portainer override backup differs; preserve both for operator review"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error).context("back up managed Portainer network override"),
|
||||
}
|
||||
fs::remove_file(path)
|
||||
.await
|
||||
.context("retire redundant Portainer network override")?;
|
||||
tracing::info!("Retired redundant managed Portainer network override; backup retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically write the manifest unit and retire known redundant managed
|
||||
/// overrides. Returns true whenever systemd needs a daemon-reload.
|
||||
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||
let path = dir.join(unit.unit_filename());
|
||||
let new_bytes = unit.render();
|
||||
|
||||
if let Ok(old) = fs::read_to_string(&path).await {
|
||||
if old == new_bytes {
|
||||
return Ok(false);
|
||||
}
|
||||
let redundant = redundant_managed_network_override(unit, dir).await?;
|
||||
let changed = fs::read_to_string(&path)
|
||||
.await
|
||||
.map(|old| old != new_bytes)
|
||||
.unwrap_or(true);
|
||||
if changed {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||
let tmp = path.with_extension("container.tmp");
|
||||
fs::write(&tmp, new_bytes.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||
let tmp = path.with_extension("container.tmp");
|
||||
fs::write(&tmp, new_bytes.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(true)
|
||||
if let Some(override_path) = &redundant {
|
||||
retire_managed_network_override(override_path).await?;
|
||||
}
|
||||
Ok(changed || redundant.is_some())
|
||||
}
|
||||
|
||||
/// Reload the user systemd manager. Required after any quadlet write
|
||||
@@ -785,7 +898,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
@@ -806,10 +923,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// The command waiter must outlive both the container grace and systemd's
|
||||
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||
.max(QUADLET_STOP_TIMEOUT)
|
||||
}
|
||||
|
||||
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||
directive_values(unit_body, "StopTimeout=")
|
||||
.last()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||
}
|
||||
|
||||
async fn systemctl_user_status(
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
return Ok(std::process::ExitStatus::from_raw(0));
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -856,6 +992,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
||||
}
|
||||
|
||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::bail!("Unit tests have no real user service manager");
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -887,12 +1027,77 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
||||
!= 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 {
|
||||
let old_ports = directive_values(old_body, "PublishPort=");
|
||||
let new_ports = directive_values(new_body, "PublishPort=");
|
||||
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 {
|
||||
let old_network = directive_values(old_body, "Network=");
|
||||
let new_network = directive_values(new_body, "Network=");
|
||||
@@ -905,7 +1110,8 @@ pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
||||
// Entrypoint= and Exec= together define what the container runs, so a drift
|
||||
// in either must recreate the container (e.g. when this renderer first
|
||||
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
|
||||
directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
directive_values(old_body, "ExecStartPre=") != directive_values(new_body, "ExecStartPre=")
|
||||
|| directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
|
||||
}
|
||||
|
||||
@@ -923,6 +1129,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
/// that systemd no longer knows about.
|
||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
let svc = format!("{unit_name}.service");
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let timeout = stop_wait_timeout(unit_name, &body);
|
||||
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||
// rootless podman a generated unit can wedge in "deactivating" while
|
||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||
@@ -930,13 +1140,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||
// blocks reinstall). If the graceful stop times out, escalate to
|
||||
// SIGKILL + reset-failed so teardown always proceeds.
|
||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||
if systemctl_user_status(&["stop", &svc], timeout)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = kill_and_reset_service(&svc).await;
|
||||
}
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
@@ -949,9 +1158,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||
// exists to avoid.
|
||||
let _ = tokio::time::timeout(
|
||||
QUADLET_STOP_TIMEOUT,
|
||||
timeout,
|
||||
Command::new("podman")
|
||||
.args(["rm", "-f", unit_name])
|
||||
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||
.status(),
|
||||
)
|
||||
.await;
|
||||
@@ -960,6 +1169,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
|
||||
/// Is the quadlet-generated service currently active?
|
||||
pub async fn is_active(service: &str) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", service])
|
||||
.status()
|
||||
@@ -970,9 +1182,143 @@ pub async fn is_active(service: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn filebrowser_prestart_credentials_are_a_required_runtime_change() {
|
||||
let unit = super::QuadletUnit {
|
||||
name: "filebrowser".into(),
|
||||
image: "registry.example/filebrowser:v2.63.23".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let rendered = unit.render();
|
||||
assert!(rendered.contains("ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/filebrowser-credentials.py --image registry.example/filebrowser:v2.63.23"));
|
||||
let old = rendered
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("ExecStartPre="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(super::exec_changed(&old, &rendered));
|
||||
assert!(!super::exec_changed(&rendered, &rendered));
|
||||
let other = super::QuadletUnit {
|
||||
name: "other-app".into(),
|
||||
..unit
|
||||
};
|
||||
assert!(!other.render().contains("filebrowser-credentials.py"));
|
||||
}
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||
for (name, grace) in [
|
||||
("bitcoin-core", 600),
|
||||
("bitcoin-knots", 600),
|
||||
("lnd", 330),
|
||||
("electrumx", 300),
|
||||
("other", 30),
|
||||
] {
|
||||
let unit = QuadletUnit {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let body = unit.render();
|
||||
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||
assert_eq!(
|
||||
stop_wait_timeout(name, &body),
|
||||
Duration::from_secs(grace + 30)
|
||||
);
|
||||
// Legacy units have no StopTimeout directive yet.
|
||||
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||
let manifest: AppManifest = serde_yaml::from_str(
|
||||
r#"
|
||||
app:
|
||||
id: custom-db
|
||||
name: Custom database
|
||||
version: 1.0.0
|
||||
stop_grace_secs: 900
|
||||
container:
|
||||
image: example/db:1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||
assert_eq!(
|
||||
stop_wait_timeout("custom-db", &unit.render()),
|
||||
Duration::from_secs(930)
|
||||
);
|
||||
assert_eq!(
|
||||
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||
let unit = sample_unit();
|
||||
let new = unit.render();
|
||||
let old = new
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.starts_with("StopTimeout=")
|
||||
&& !line.starts_with("TimeoutStopSec=")
|
||||
&& !line.starts_with("ExecStop=")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!exec_changed(&old, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&old, &new));
|
||||
assert!(!health_cmd_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||
if !generator.exists() {
|
||||
eprintln!(
|
||||
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let unit = QuadletUnit {
|
||||
name: "grace-test".into(),
|
||||
image: "localhost/test:latest".into(),
|
||||
stop_grace_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||
let output = std::process::Command::new(generator)
|
||||
.args(["--user", "--dryrun"])
|
||||
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
let stop = generated
|
||||
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||
.unwrap();
|
||||
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||
assert!(
|
||||
stop < remove,
|
||||
"Legacy container must stop gracefully before removal"
|
||||
);
|
||||
assert!(generated.contains("--stop-timeout 600"));
|
||||
assert!(generated.contains("TimeoutStopSec=615"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
@@ -1160,6 +1506,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]
|
||||
fn quote_environment_quotes_values_with_spaces() {
|
||||
assert_eq!(
|
||||
@@ -1372,6 +1730,26 @@ app:
|
||||
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]
|
||||
fn from_manifest_slirp4netns_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
@@ -1393,6 +1771,24 @@ app:
|
||||
assert!(!s.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_network_preserves_same_node_upstreams_without_aliases() {
|
||||
let m = AppManifest::parse(include_str!(
|
||||
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))
|
||||
.unwrap();
|
||||
let rendered = QuadletUnit::from_manifest(&m, "nginx-proxy-manager").render();
|
||||
assert_eq!(
|
||||
rendered
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Network="))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"]
|
||||
);
|
||||
assert!(!rendered.contains("NetworkAlias="));
|
||||
assert!(!rendered.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_pasta_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
@@ -1722,6 +2118,133 @@ app:
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
fs::write(&path, old).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(path.with_extension("conf.retired"))
|
||||
.await
|
||||
.unwrap(),
|
||||
old
|
||||
);
|
||||
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("portainer.container"))
|
||||
.await
|
||||
.unwrap()
|
||||
.matches("Network=slirp4netns")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
for custom in [
|
||||
"[Container]\nNetwork=custom-net\n",
|
||||
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
|
||||
] {
|
||||
fs::write(&path, custom).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
write_if_changed(&unit, dir.path()).await.unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
|
||||
}
|
||||
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
|
||||
.await
|
||||
.unwrap();
|
||||
unit.network = NetworkMode::Pasta;
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
unit.network = NetworkMode::Slirp4netns;
|
||||
fs::write(path.with_extension("conf.retired"), "different backup")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write_if_changed(&unit, dir.path()).await.is_err());
|
||||
assert!(path.exists(), "failure must preserve the active override");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn network_aliases_changed_detects_network_mode_drift() {
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
|
||||
@@ -1,80 +1,155 @@
|
||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||
//!
|
||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||
//! then releases the file to anyone presenting that payment hash.
|
||||
//!
|
||||
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||
//! because an unpaid invoice represents no money.
|
||||
//! Durable seller-side entitlements for peer-file invoices and on-chain sales.
|
||||
//! Payment records must outlive browser polling, process restarts and invoice
|
||||
//! expiry: an invoice can settle while the buyer is disconnected.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||
/// to pay an invoice and download, short enough to keep the map small.
|
||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
static WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Entitlement {
|
||||
content_id: String,
|
||||
price_sats: u64,
|
||||
paid: bool,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Drop expired entries. Caller must hold the lock.
|
||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||
fn path(data_dir: &Path, token: &str) -> PathBuf {
|
||||
data_dir.join("content-entitlements").join(format!(
|
||||
"{}.json",
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.insert(
|
||||
payment_hash.to_string(),
|
||||
Entitlement {
|
||||
content_id: content_id.to_string(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||
pub async fn mark_paid(payment_hash: &str) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
if let Some(e) = map.get_mut(payment_hash) {
|
||||
e.paid = true;
|
||||
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
|
||||
match fs::read(path(data_dir, token)).await {
|
||||
Ok(bytes) => Ok(Some(
|
||||
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).context("Reading payment entitlement"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content_id + price an entitlement was issued for, if still live.
|
||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
|
||||
let target = path(data_dir, token);
|
||||
let dir = target.parent().unwrap();
|
||||
fs::create_dir_all(dir).await?;
|
||||
let tmp = target.with_extension("tmp");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await?;
|
||||
file.write_all(&serde_json::to_vec(entry)?).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&tmp, &target).await?;
|
||||
fs::File::open(dir).await?.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||
/// This is the gate the content server consults to release a file.
|
||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
/// Save before exposing an invoice/address to the buyer. Never overwrite an
|
||||
/// existing payment or silently rebind its token to another item or price.
|
||||
pub async fn record_pending(
|
||||
data_dir: &Path,
|
||||
token: &str,
|
||||
content_id: &str,
|
||||
price_sats: u64,
|
||||
) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
if let Some(existing) = read(data_dir, token).await? {
|
||||
anyhow::ensure!(
|
||||
existing.content_id == content_id && existing.price_sats == price_sats,
|
||||
"Payment entitlement mismatch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
write(
|
||||
data_dir,
|
||||
token,
|
||||
&Entitlement {
|
||||
content_id: content_id.into(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
let mut entry = read(data_dir, token)
|
||||
.await?
|
||||
.context("Unknown payment entitlement")?;
|
||||
entry.paid = true;
|
||||
write(data_dir, token, &entry).await
|
||||
}
|
||||
|
||||
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> {
|
||||
Ok(read(data_dir, token)
|
||||
.await?
|
||||
.map(|e| (e.content_id, e.price_sats)))
|
||||
}
|
||||
|
||||
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool {
|
||||
read(data_dir, token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|e| e.paid && e.content_id == content_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!is_paid_for(dir.path(), "hash", "file").await);
|
||||
mark_paid(dir.path(), "hash").await.unwrap();
|
||||
// All reads reopen disk; no process-local entitlement map exists.
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(!is_paid_for(dir.path(), "hash", "other").await);
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(record_pending(dir.path(), "hash", "other", 12)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(record_pending(dir.path(), "hash", "file", 13)
|
||||
.await
|
||||
.is_err());
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
assert!(!is_paid_for(other.path(), "hash", "file").await);
|
||||
assert!(mark_paid(dir.path(), "unknown").await.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_or_unwritable_records_fail_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(path(dir.path(), "../../token"), b"broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(lookup(dir.path(), "../../token").await.is_err());
|
||||
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
|
||||
assert!(record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.is_err());
|
||||
let file = dir.path().join("not-directory");
|
||||
fs::write(&file, b"x").await.unwrap();
|
||||
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
const OWNED_DIR: &str = "purchased-content";
|
||||
const OWNED_INDEX: &str = "owned.json";
|
||||
@@ -66,20 +68,51 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||
.join(sanitize(content_id))
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
|
||||
match fs::read_to_string(index_path(data_dir)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => OwnedIndex::default(),
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.context("Invalid purchase index; existing records were preserved"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
|
||||
Err(error) => Err(error).context("Reading purchase index"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
load_index_checked(data_dir).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().context("Purchase path has no parent")?;
|
||||
fs::create_dir_all(parent).await?;
|
||||
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
|
||||
let result = async {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&temp, path).await?;
|
||||
fs::File::open(parent).await?.sync_all().await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temp).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||
let root = owned_root(data_dir);
|
||||
fs::create_dir_all(&root)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", root.display()))?;
|
||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||
fs::write(index_path(data_dir), content)
|
||||
atomic_write(&index_path(data_dir), content.as_bytes())
|
||||
.await
|
||||
.context("writing owned index")
|
||||
}
|
||||
@@ -98,17 +131,15 @@ pub async fn record_purchase(
|
||||
ecash_backend: &str,
|
||||
purchased_at: &str,
|
||||
) -> Result<()> {
|
||||
// Read-modify-write must be one serialized transaction. Never replace a
|
||||
// damaged index with an empty one, and never expose partially written bytes.
|
||||
let _lock = PURCHASE_WRITES.lock().await;
|
||||
let mut index = load_index_checked(data_dir).await?;
|
||||
let path = bytes_path(data_dir, onion, content_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
fs::write(&path, bytes)
|
||||
atomic_write(&path, bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||
|
||||
let mut index = load_index(data_dir).await;
|
||||
let entry = OwnedItem {
|
||||
onion: onion.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
@@ -131,6 +162,11 @@ pub async fn record_purchase(
|
||||
save_index(data_dir, &index).await
|
||||
}
|
||||
|
||||
/// Payment decisions must not interpret an unreadable index as no purchases.
|
||||
pub async fn list_owned_checked(data_dir: &Path) -> Result<Vec<OwnedItem>> {
|
||||
Ok(load_index_checked(data_dir).await?.items)
|
||||
}
|
||||
|
||||
/// Every item this node owns.
|
||||
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||
load_index(data_dir).await.items
|
||||
@@ -165,3 +201,90 @@ pub async fn read_owned(
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
Some((mime, bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for n in 0..24 {
|
||||
let root = dir.path().to_path_buf();
|
||||
jobs.spawn(async move {
|
||||
let id = format!("file-{n}");
|
||||
record_purchase(
|
||||
&root,
|
||||
"seller.onion",
|
||||
&id,
|
||||
&id,
|
||||
"text/plain",
|
||||
id.as_bytes(),
|
||||
5,
|
||||
"lightning",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
while let Some(result) = jobs.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
for n in 0..24 {
|
||||
let id = format!("file-{n}");
|
||||
assert!(is_owned(dir.path(), "seller.onion", &id).await);
|
||||
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
|
||||
assert_eq!(mime, "text/plain");
|
||||
assert_eq!(bytes, id.as_bytes());
|
||||
}
|
||||
record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"file-0",
|
||||
"file-0",
|
||||
"text/plain",
|
||||
b"updated",
|
||||
5,
|
||||
"lightning",
|
||||
"later",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
assert_eq!(
|
||||
read_owned(dir.path(), "seller.onion", "file-0")
|
||||
.await
|
||||
.unwrap()
|
||||
.1,
|
||||
b"updated"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
|
||||
fs::write(index_path(dir.path()), b"damaged but preserve me")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"new",
|
||||
"new",
|
||||
"text/plain",
|
||||
b"bytes",
|
||||
5,
|
||||
"lightning",
|
||||
"now"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(index_path(dir.path())).await.unwrap(),
|
||||
b"damaged but preserve me"
|
||||
);
|
||||
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,11 @@ pub enum ServeResult {
|
||||
Forbidden,
|
||||
/// Content not found.
|
||||
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.
|
||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
) -> 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 item = match catalog.items.iter().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
@@ -296,46 +334,6 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats, .. } => {
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
AccessControl::PeersOnly => {
|
||||
if !is_known_peer {
|
||||
return Ok(ServeResult::Forbidden);
|
||||
}
|
||||
}
|
||||
AccessControl::Free => {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -354,55 +352,194 @@ pub async fn serve_content(
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
let total_size = metadata.len();
|
||||
|
||||
// Handle range request for streaming
|
||||
if let Some(range) = range {
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
let len = (end - start + 1) as usize;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(&file_path)
|
||||
.await
|
||||
.context("Failed to open content file")?;
|
||||
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 {
|
||||
bytes: buf,
|
||||
mime_type: item.mime_type.clone(),
|
||||
start,
|
||||
end,
|
||||
total: total_size,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file_path)
|
||||
.await
|
||||
.context("Failed to read content file")?;
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats, .. } => {
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify(token.to_owned(), *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
AccessControl::PeersOnly => {
|
||||
if !is_known_peer {
|
||||
return Ok(ServeResult::Forbidden);
|
||||
}
|
||||
}
|
||||
AccessControl::Free => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||
return slice_prepared_content(bytes, range, mime);
|
||||
}
|
||||
Err(error) => return Err(error).context("Opening shared content"),
|
||||
};
|
||||
let metadata = file.metadata().await?;
|
||||
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
|
||||
.context("Reading shared content range")?;
|
||||
return Ok(ServeResult::Partial {
|
||||
bytes,
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
});
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.await
|
||||
.context("Reading shared content")?;
|
||||
Ok(ServeResult::Ok(bytes, mime))
|
||||
}
|
||||
|
||||
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||
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.
|
||||
@@ -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.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
@@ -725,3 +862,301 @@ mod prune_missing_content_tests {
|
||||
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
|
||||
// of its own that no amount of downtime erodes.
|
||||
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.
|
||||
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>) {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
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.
|
||||
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;
|
||||
if installed.insert(name.to_string()) {
|
||||
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
|
||||
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
||||
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;
|
||||
if installed.remove(name) {
|
||||
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
|
||||
/// still picked up whenever it is next seen alive.
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
@@ -650,6 +664,10 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
|
||||
start_stopped_app_stacks(data_dir).await
|
||||
}
|
||||
|
||||
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
|
||||
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
|
||||
}
|
||||
|
||||
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let mut report = RecoveryReport {
|
||||
@@ -663,24 +681,27 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Recovering stopped {} stack containers after boot",
|
||||
stack.name
|
||||
);
|
||||
// Healthy members must never acquire a restarting overlay merely
|
||||
// because the periodic recovery scan ran. Queue existing stopped
|
||||
// members only; recheck each immediately before starting below.
|
||||
let mut pending = Vec::new();
|
||||
for container in stack.containers {
|
||||
let state = container_state(container).await;
|
||||
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
|
||||
pending.push((*container).to_string());
|
||||
}
|
||||
}
|
||||
if pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!("Recovering stopped {} stack containers", stack.name);
|
||||
repair_stack_network_aliases(stack).await;
|
||||
|
||||
// Register the whole stack up front: the per-member dependency waits
|
||||
// below can take minutes, and the UI should say "Restarting", not
|
||||
// "Stopped", for members still queued behind them.
|
||||
pending_boot_starts_add(
|
||||
stack
|
||||
.containers
|
||||
.iter()
|
||||
.filter(|c| !user_stopped.contains(**c))
|
||||
.map(|c| (*c).to_string()),
|
||||
);
|
||||
pending_boot_starts_add(pending.iter().cloned());
|
||||
|
||||
for container in stack.containers {
|
||||
if !pending.iter().any(|name| name.as_str() == *container) {
|
||||
continue;
|
||||
}
|
||||
if user_stopped.contains(*container) {
|
||||
info!("Skipping user-stopped container: {}", container);
|
||||
continue;
|
||||
@@ -692,8 +713,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
|
||||
_ => {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
@@ -1497,3 +1518,47 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stack_recovery_overlay_tests {
|
||||
use super::stack_member_needs_recovery;
|
||||
#[test]
|
||||
fn only_existing_stopped_members_receive_recovery_overlay() {
|
||||
for state in [
|
||||
None,
|
||||
Some("running"),
|
||||
Some("paused"),
|
||||
Some("restarting"),
|
||||
Some("removing"),
|
||||
] {
|
||||
assert!(!stack_member_needs_recovery(state, false));
|
||||
}
|
||||
for state in ["exited", "stopped", "created", "configured"] {
|
||||
assert!(stack_member_needs_recovery(Some(state), false));
|
||||
assert!(!stack_member_needs_recovery(Some(state), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ pub enum PackageState {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
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,
|
||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -297,6 +301,8 @@ pub enum InstallPhase {
|
||||
/// `podman pull` in progress (the longest phase — up to several
|
||||
/// minutes for large images on slow networks).
|
||||
PullingImage,
|
||||
/// Orchestrator owns download/build and startup as one operation.
|
||||
PreparingApp,
|
||||
/// Creating data directories, writing app-specific configs
|
||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||
CreatingContainer,
|
||||
|
||||
@@ -5,8 +5,45 @@
|
||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
|
||||
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 18091, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
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()
|
||||
.no_proxy()
|
||||
.redirect(delivery_redirect_policy(single))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.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,
|
||||
/// 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> {
|
||||
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();
|
||||
match rb.send().await {
|
||||
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
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
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
|
||||
/// every caller has a data dir in scope.
|
||||
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> {
|
||||
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
|
||||
fips_timeout: None,
|
||||
service: 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
|
||||
/// (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 {
|
||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// 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();
|
||||
self.spawn_record(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) {
|
||||
match self.try_fips_get().await? {
|
||||
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();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
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);
|
||||
for (k, v) in &self.headers {
|
||||
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(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)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
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);
|
||||
for (k, v) in &self.headers {
|
||||
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)),
|
||||
// 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)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
||||
.context("Invalid Tor SOCKS proxy URL")?;
|
||||
reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("Build Tor HTTP client")
|
||||
@@ -759,4 +843,181 @@ mod tests {
|
||||
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,12 +501,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
out
|
||||
}
|
||||
|
||||
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<std::net::SocketAddr> {
|
||||
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out: Vec<u16> = ports
|
||||
let mut out: Vec<std::net::SocketAddr> = ports
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("protocol")
|
||||
@@ -515,9 +515,19 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
.eq_ignore_ascii_case("tcp")
|
||||
})
|
||||
.filter_map(|p| {
|
||||
p.get("host_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|port| u16::try_from(port).ok())
|
||||
let port = p.get("host_port")?.as_u64()?;
|
||||
let port = u16::try_from(port).ok().filter(|port| *port != 0)?;
|
||||
let bind = p.get("host_ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Wildcard listeners are reachable through the corresponding
|
||||
// loopback family. Explicit binds must be probed at that address:
|
||||
// probing a WireGuard-only port on 127.0.0.1 creates false failures
|
||||
// and endlessly restarts an otherwise healthy app.
|
||||
let address: std::net::IpAddr = match bind {
|
||||
"" | "0.0.0.0" => "127.0.0.1".parse().ok()?,
|
||||
"::" => "::1".parse().ok()?,
|
||||
explicit => explicit.parse().ok()?,
|
||||
};
|
||||
Some(std::net::SocketAddr::new(address, port))
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
@@ -525,11 +535,11 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
out
|
||||
}
|
||||
|
||||
async fn host_ports_ready(ports: &[u16]) -> bool {
|
||||
async fn host_ports_ready(ports: &[std::net::SocketAddr]) -> bool {
|
||||
for port in ports {
|
||||
let ready = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
|
||||
tokio::net::TcpStream::connect(*port),
|
||||
)
|
||||
.await
|
||||
.is_ok_and(|r| r.is_ok());
|
||||
@@ -1662,4 +1672,51 @@ mod tests {
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn published_port_probes_preserve_explicit_bind_addresses() {
|
||||
let c = serde_json::json!({"Ports": [
|
||||
{"host_ip":"127.0.0.1","host_port":8081,"protocol":"tcp"},
|
||||
{"host_ip":"10.77.0.2","host_port":18081,"protocol":"tcp"},
|
||||
{"host_ip":"10.77.0.2","host_port":18443,"protocol":"tcp"},
|
||||
{"host_ip":"::1","host_port":8082,"protocol":"tcp"}
|
||||
]});
|
||||
let targets = host_tcp_ports_from_container(&c);
|
||||
for target in [
|
||||
"127.0.0.1:8081",
|
||||
"10.77.0.2:18081",
|
||||
"10.77.0.2:18443",
|
||||
"[::1]:8082",
|
||||
] {
|
||||
assert!(targets.contains(&target.parse().unwrap()));
|
||||
}
|
||||
assert!(!targets.contains(&"127.0.0.1:18081".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_port_probes_normalize_wildcards_and_ignore_invalid_entries() {
|
||||
let c = serde_json::json!({"Ports": [
|
||||
{"host_ip":"0.0.0.0","host_port":8080},
|
||||
{"host_ip":"","host_port":8080},
|
||||
{"host_ip":"::","host_port":8080},
|
||||
{"host_ip":"10.0.0.1","host_port":53,"protocol":"udp"},
|
||||
{"host_ip":"bad","host_port":8080},
|
||||
{"host_port":0}, {"host_port":65536}, {"container_port":80}
|
||||
]});
|
||||
let targets = host_tcp_ports_from_container(&c);
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert!(targets.contains(&"127.0.0.1:8080".parse().unwrap()));
|
||||
assert!(targets.contains(&"[::1]:8080".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_reaches_non_default_loopback_and_detects_closed_port() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
assert!(host_ports_ready(&[address]).await);
|
||||
// Same port, wrong local address reproduces the former false failure.
|
||||
let wrong = std::net::SocketAddr::new("127.0.0.1".parse().unwrap(), address.port());
|
||||
assert!(!host_ports_ready(&[wrong]).await);
|
||||
drop(listener);
|
||||
assert!(!host_ports_ready(&[address]).await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,30 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// True when `record` is the node's own identity: the one whose ed25519 key
|
||||
/// is the node key (`server_info.pubkey`). `identity.list` reports this as
|
||||
/// `is_node`, and clients must never offer it as an app signer.
|
||||
pub fn is_node_identity(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
|
||||
!node_pubkey_hex.is_empty() && record.pubkey_hex == node_pubkey_hex
|
||||
}
|
||||
|
||||
/// True when the app identity picker hides `record`, mirroring
|
||||
/// `NostrIdentityPicker.vue`'s filter exactly: the node identity
|
||||
/// (`is_node`), any `node-*` id and any identity named "Node".
|
||||
pub(crate) fn is_hidden_from_app_signer(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
|
||||
// Match ECMAScript trim exactly: Rust includes U+0085 and excludes U+FEFF.
|
||||
let trim_js = |value: &str| value.trim_matches(is_js_whitespace).to_lowercase();
|
||||
is_node_identity(record, node_pubkey_hex)
|
||||
|| trim_js(&record.id).starts_with("node-")
|
||||
|| trim_js(&record.name) == "node"
|
||||
}
|
||||
|
||||
fn is_js_whitespace(c: char) -> bool {
|
||||
matches!(c, '\u{0009}'..='\u{000D}' | '\u{0020}' | '\u{00A0}' | '\u{1680}'
|
||||
| '\u{2000}'..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}'
|
||||
| '\u{205F}' | '\u{3000}' | '\u{FEFF}')
|
||||
}
|
||||
|
||||
impl IdentityManager {
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||
@@ -150,6 +174,30 @@ impl IdentityManager {
|
||||
Ok((identities, default_id))
|
||||
}
|
||||
|
||||
/// Nostr public keys of the identities an app may sign with through the
|
||||
/// NIP-07 bridge, as sorted, de-duplicated, comma-joined lowercase hex.
|
||||
///
|
||||
/// Leaves out what the identity picker hides (`is_hidden_from_app_signer`)
|
||||
/// and identities without a Nostr key (they cannot sign). Empty when no
|
||||
/// identity qualifies.
|
||||
pub async fn app_signable_nostr_pubkeys(&self, node_pubkey_hex: &str) -> Result<String> {
|
||||
let (identities, _) = self.list().await?;
|
||||
let mut pubkeys: Vec<String> = identities
|
||||
.iter()
|
||||
.filter(|r| !is_hidden_from_app_signer(r, node_pubkey_hex))
|
||||
.filter_map(|r| r.nostr_pubkey.as_deref())
|
||||
.map(|key| {
|
||||
anyhow::ensure!(key.len() == 64, "invalid app owner Nostr public key");
|
||||
nostr_sdk::PublicKey::from_hex(key)
|
||||
.map(|key| key.to_hex())
|
||||
.context("invalid app owner Nostr public key")
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
Ok(pubkeys.join(","))
|
||||
}
|
||||
|
||||
/// Create a new identity.
|
||||
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
@@ -966,6 +1014,167 @@ mod tests {
|
||||
assert_ne!(default_id, Some(r1.id));
|
||||
}
|
||||
|
||||
fn record(id: &str, name: &str, pubkey_hex: &str) -> IdentityRecord {
|
||||
IdentityRecord {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
purpose: IdentityPurpose::Personal,
|
||||
pubkey_hex: pubkey_hex.to_string(),
|
||||
did: String::new(),
|
||||
dht_did: None,
|
||||
created_at: String::new(),
|
||||
nostr_pubkey: None,
|
||||
nostr_npub: None,
|
||||
profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_node_identity_matches_only_the_node_pubkey() {
|
||||
let node = "ab".repeat(32);
|
||||
let other = "cd".repeat(32);
|
||||
assert!(is_node_identity(&record("uuid-1", "Laptop", &node), &node));
|
||||
assert!(!is_node_identity(
|
||||
&record("uuid-1", "Laptop", &other),
|
||||
&node
|
||||
));
|
||||
// An unknown node key matches nothing, not the records without a key.
|
||||
assert!(!is_node_identity(&record("uuid-1", "Laptop", ""), ""));
|
||||
// The id and name rules belong to the picker filter, not to `is_node`.
|
||||
assert!(!is_node_identity(
|
||||
&record("node-abc", "Node", &other),
|
||||
&node
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hidden_from_app_signer_mirrors_the_picker_rules() {
|
||||
let node = "ab".repeat(32);
|
||||
let other = "cd".repeat(32);
|
||||
let hidden = |id: &str, name: &str, pk: &str| {
|
||||
is_hidden_from_app_signer(&record(id, name, pk), &node)
|
||||
};
|
||||
// is_node: matched by key alone, whatever the id and name.
|
||||
assert!(hidden("uuid-1", "Laptop", &node));
|
||||
// node-* id, any case, surrounding whitespace ignored.
|
||||
assert!(hidden("node-0123456789abcdef", "Laptop", &other));
|
||||
assert!(hidden(" NODE-x ", "Laptop", &other));
|
||||
assert!(hidden("Node-x", "Laptop", &other));
|
||||
// The name "Node", any case, surrounding whitespace ignored.
|
||||
assert!(hidden("uuid-1", "Node", &other));
|
||||
assert!(hidden("uuid-1", " nODe\t", &other));
|
||||
assert!(hidden("\u{FEFF}NODE-x\u{FEFF}", "Laptop", &other));
|
||||
assert!(hidden("uuid-1", "\u{FEFF}Node\u{FEFF}", &other));
|
||||
// ECMAScript keeps U+0085; do not add owners that the picker hides,
|
||||
// or hide identities that it offers.
|
||||
assert!(!hidden("uuid-1", "\u{0085}Node\u{0085}", &other));
|
||||
// Near misses stay visible.
|
||||
assert!(!hidden("uuid-1", "Laptop", &other));
|
||||
assert!(!hidden("my-node-1", "Node 2", &other));
|
||||
assert!(!hidden("nodes", "Nodes", &other));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_signable_nostr_pubkeys_mirror_the_identity_picker() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mgr = IdentityManager::new(dir.path()).await.unwrap();
|
||||
let personal = mgr
|
||||
.create("Personal".to_string(), IdentityPurpose::Personal)
|
||||
.await
|
||||
.unwrap();
|
||||
let business = mgr
|
||||
.create("Business".to_string(), IdentityPurpose::Business)
|
||||
.await
|
||||
.unwrap();
|
||||
// The node identity as mirrored at startup: a `node-` id named
|
||||
// "Node", given a Nostr key so only the id and name rules hide it.
|
||||
let mirrored_key = SigningKey::generate(&mut OsRng);
|
||||
let mirrored = mgr
|
||||
.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, mirrored_key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mirrored.id.starts_with("node-"));
|
||||
mgr.create_nostr_key(&mirrored.id).await.unwrap();
|
||||
// A user-created identity named "Node" is hidden by the picker too.
|
||||
let named_node = mgr
|
||||
.create(" node ".to_string(), IdentityPurpose::Anonymous)
|
||||
.await
|
||||
.unwrap();
|
||||
// The node key belongs to an identity with a uuid id and an ordinary
|
||||
// name, so only the `is_node` match can hide it.
|
||||
let laptop = mgr
|
||||
.create("Laptop".to_string(), IdentityPurpose::Personal)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!laptop.id.starts_with("node-"));
|
||||
|
||||
let (all, _) = mgr.list().await.unwrap();
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|r| r.id == mirrored.id && r.nostr_pubkey.is_some()));
|
||||
assert!(all.iter().any(|r| r.id == named_node.id));
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|r| r.id == laptop.id && r.nostr_pubkey.is_some()));
|
||||
|
||||
let mut expected = vec![
|
||||
personal.nostr_pubkey.unwrap().to_ascii_lowercase(),
|
||||
business.nostr_pubkey.unwrap().to_ascii_lowercase(),
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys(&laptop.pubkey_hex)
|
||||
.await
|
||||
.unwrap(),
|
||||
expected.join(",")
|
||||
);
|
||||
// Without the node key, the same identity is offered like any other.
|
||||
let mut with_laptop = expected.clone();
|
||||
with_laptop.push(laptop.nostr_pubkey.unwrap().to_ascii_lowercase());
|
||||
with_laptop.sort();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys("").await.unwrap(),
|
||||
with_laptop.join(",")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_owner_pubkeys_reject_malformed_key_material() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mgr = IdentityManager::new(dir.path()).await.unwrap();
|
||||
let identity = mgr
|
||||
.create("Owner".into(), IdentityPurpose::Personal)
|
||||
.await
|
||||
.unwrap();
|
||||
let path = mgr.identities_dir.join(format!("{}.json", identity.id));
|
||||
let original = fs::read(&path).await.unwrap();
|
||||
for invalid in ["", "not-a-key", "owner,another-owner", "\nINJECTED=true"] {
|
||||
let mut data: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
data["nostr_pubkey_hex"] = serde_json::json!(invalid);
|
||||
fs::write(&path, serde_json::to_vec(&data).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mgr.app_signable_nostr_pubkeys("").await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_signable_nostr_pubkeys_is_empty_with_only_the_node_identity() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mgr = IdentityManager::new(dir.path()).await.unwrap();
|
||||
let node_key = SigningKey::generate(&mut OsRng);
|
||||
let node_pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
mgr.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, node_key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys(&node_pubkey_hex)
|
||||
.await
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_default_shifts() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -70,6 +70,8 @@ mod node_message;
|
||||
mod nostr_discovery;
|
||||
mod nostr_handshake;
|
||||
mod nostr_relays;
|
||||
#[cfg(test)]
|
||||
mod nostr_security_tests;
|
||||
mod peers;
|
||||
mod port_allocator;
|
||||
mod rate_limit;
|
||||
@@ -256,6 +258,10 @@ async fn main() -> Result<()> {
|
||||
boot_report.recovered, boot_report.total, boot_report.failed
|
||||
);
|
||||
}
|
||||
// Disk manifests must be stable before the initial load and all later
|
||||
// catalog reloads. Do not move this into the background doctor bootstrap.
|
||||
bootstrap::ensure_runtime_assets_ready().await;
|
||||
|
||||
// Construct the container orchestrator once. In prod mode we load the
|
||||
// on-disk app manifests, do an initial adoption pass, and spawn the
|
||||
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! to a full chip erase before write.
|
||||
//!
|
||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||
//! image, verify it, then run `write_flash --erase-all 0x0 <image>` with
|
||||
//! the packaged esptool executable.
|
||||
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||
@@ -49,32 +50,18 @@ impl FlashBoard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||
///
|
||||
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||
/// the board manually whenever this returns `None`.
|
||||
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||
_ => None,
|
||||
}
|
||||
/// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not
|
||||
/// board wiring. Require an explicit board until a board-specific identity is
|
||||
/// available; guessing a Heltec model can write incompatible firmware.
|
||||
pub fn resolve_flash_board(_info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashStage {
|
||||
Downloading,
|
||||
Preparing,
|
||||
Erasing,
|
||||
Writing,
|
||||
Autoinstalling,
|
||||
@@ -101,11 +88,10 @@ const LOG_TAIL_MAX: usize = 200;
|
||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||
/// forever — generous enough to never trigger on a legitimately slow
|
||||
/// multi-hundred-MB transfer.
|
||||
/// Deadline for preparation and warning threshold for the active flasher.
|
||||
/// A download can be cancelled safely. An active write retains ownership until
|
||||
/// its subprocess exits, even after this threshold: reporting an aborted job
|
||||
/// while a detached writer continues would let a retry corrupt the device.
|
||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
/// How long to wait for MeshService::stop() to release the serial port
|
||||
@@ -247,6 +233,16 @@ fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("mesh").join("firmware-cache")
|
||||
}
|
||||
|
||||
async fn invalidate_radio_settings_marker(data_dir: &Path) -> Result<()> {
|
||||
// A full-chip flash erased the device's preferences. A previous host-side
|
||||
// marker is no longer evidence that this radio has the requested settings.
|
||||
match tokio::fs::remove_file(data_dir.join("meshcore-radio-params.json")).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error).context("Firmware written, but old radio-settings marker could not be cleared; reconnect is paused"),
|
||||
}
|
||||
}
|
||||
|
||||
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||
/// the *entire* request including streaming the response body, which would
|
||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||
@@ -314,6 +310,10 @@ pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
#[serde(default)]
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -322,8 +322,24 @@ struct GithubRelease {
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
async fn register_flash_job(handle: &FlashJobHandle, job: &Arc<FlashJob>) -> Result<()> {
|
||||
// Keep checking and registering under one lock: concurrent RPCs must
|
||||
// never start two writers on the same device.
|
||||
let mut existing = handle.try_write().map_err(|_| anyhow::anyhow!(
|
||||
"The radio is being inspected or reconfigured; wait for that operation to finish before flashing"
|
||||
))?;
|
||||
if let Some(current) = existing.as_ref() {
|
||||
if !current.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
*existing = Some(Arc::clone(job));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a flash job in the background. Returns as soon as the job has been
|
||||
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||
/// registered — preparation and listener shutdown run in the background.
|
||||
/// Callers poll `FlashJobHandle` via
|
||||
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||
pub async fn start_flash_job(
|
||||
handle: &FlashJobHandle,
|
||||
@@ -333,21 +349,44 @@ pub async fn start_flash_job(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let existing = handle.read().await;
|
||||
if let Some(job) = existing.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
// Missing/corrupt tooling must fail before stopping a working radio or
|
||||
// registering a job that can never reach the serial device.
|
||||
if matches!(family, DeviceType::Meshtastic | DeviceType::Meshcore) {
|
||||
preflight_esptool().await?;
|
||||
}
|
||||
|
||||
let job = FlashJob::new(board, family, path.clone());
|
||||
*handle.write().await = Some(Arc::clone(&job));
|
||||
register_flash_job(handle, &job).await?;
|
||||
|
||||
let bg_job = Arc::clone(&job);
|
||||
let bg_service = Arc::clone(mesh_service);
|
||||
let task = tokio::spawn(async move {
|
||||
// Downloads and checksum checks do not need exclusive serial access.
|
||||
// Keep a working listener alive if upstream/download verification fails.
|
||||
let prepared_image = if matches!(family, DeviceType::Meshcore | DeviceType::Meshtastic) {
|
||||
match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
fetch_esptool_image(board, family, &data_dir, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(image)) => Some(image),
|
||||
result => {
|
||||
let error = match result {
|
||||
Ok(Err(error)) => error,
|
||||
_ => anyhow::anyhow!(
|
||||
"Firmware download exceeded the time limit; radio was not changed"
|
||||
),
|
||||
};
|
||||
bg_job.fail(&error).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Cancellation is safe during download. Once listener shutdown starts,
|
||||
// allow that bounded operation to finish instead of orphaning its task.
|
||||
bg_job.set_stage(FlashStage::Preparing).await;
|
||||
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||
// the listener's hold on the port before touching it. This USED
|
||||
// TO run synchronously in start_flash_job before the job was even
|
||||
@@ -404,17 +443,31 @@ pub async fn start_flash_job(
|
||||
// subsequent mesh.flash-device call failed with "already in
|
||||
// progress" until the service was restarted). Generous enough that
|
||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||
let result = match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let flash = run_flash(
|
||||
board,
|
||||
family,
|
||||
&data_dir,
|
||||
&path,
|
||||
prepared_image.as_deref(),
|
||||
&bg_job,
|
||||
);
|
||||
tokio::pin!(flash);
|
||||
let result = match tokio::time::timeout(MAX_JOB_DURATION, &mut flash).await {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||
MAX_JOB_DURATION.as_secs() / 60
|
||||
)),
|
||||
Err(_) if bg_job.snapshot().await.stage == FlashStage::Downloading => Err(
|
||||
anyhow::anyhow!("Firmware download exceeded the time limit; radio was not written"),
|
||||
),
|
||||
Err(_) => {
|
||||
// Dropping this future does NOT stop its flash subprocess.
|
||||
// Keep the job busy until it exits, rather than allowing a
|
||||
// second writer while the first one still owns the device.
|
||||
bg_job.push_log("Flashing is taking longer than expected; waiting for the active tool to exit before allowing another operation").await;
|
||||
flash.await
|
||||
}
|
||||
};
|
||||
let result = match result {
|
||||
Ok(()) => invalidate_radio_settings_marker(&data_dir).await,
|
||||
error => error,
|
||||
};
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
@@ -423,7 +476,6 @@ pub async fn start_flash_job(
|
||||
bg_job
|
||||
.push_log("Flash completed successfully".to_string())
|
||||
.await;
|
||||
bg_job.finish().await;
|
||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -434,7 +486,6 @@ pub async fn start_flash_job(
|
||||
// erase_flash failed", no actual esptool stderr).
|
||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(e).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +501,9 @@ pub async fn start_flash_job(
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
if let Err(error) = &result {
|
||||
bg_job.fail(error).await;
|
||||
}
|
||||
// Deliberately do NOT auto-restart the listener here. A failed
|
||||
// flash means we can't vouch for the board's state — reopening
|
||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||
@@ -499,6 +553,7 @@ pub async fn start_flash_job(
|
||||
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||
}
|
||||
}
|
||||
bg_job.finish().await;
|
||||
});
|
||||
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||
|
||||
@@ -510,12 +565,13 @@ async fn run_flash(
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
prepared_image: Option<&Path>,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
match family {
|
||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||
esptool_erase_and_write(path, &image, job).await
|
||||
let image = prepared_image.context("Firmware was not prepared before serial access")?;
|
||||
esptool_erase_and_write(path, image, job).await
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let lora_region = super::load_config(data_dir)
|
||||
@@ -664,15 +720,65 @@ async fn fetch_meshcore_image(
|
||||
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||
})?;
|
||||
|
||||
anyhow::ensure!(
|
||||
Path::new(&asset.name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
== Some(asset.name.as_str()),
|
||||
"Invalid firmware asset filename"
|
||||
);
|
||||
let out_path = cache.join(&asset.name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||
return Ok(out_path);
|
||||
if verify_meshcore_asset(&out_path, asset).await.is_ok() {
|
||||
job.push_log(format!("Using verified cached {}", asset.name))
|
||||
.await;
|
||||
return Ok(out_path);
|
||||
}
|
||||
tokio::fs::remove_file(&out_path)
|
||||
.await
|
||||
.context("Removing invalid cached firmware")?;
|
||||
job.push_log("Cached firmware failed verification; downloading a fresh copy")
|
||||
.await;
|
||||
}
|
||||
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||
if let Err(error) = verify_meshcore_asset(&out_path, asset).await {
|
||||
// A partial/unverified download must not become next attempt's cache.
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn verify_meshcore_asset(path: &Path, asset: &GithubAsset) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let size = asset
|
||||
.size
|
||||
.context("MeshCore release did not provide firmware size")?;
|
||||
anyhow::ensure!(
|
||||
(1..=16 * 1024 * 1024).contains(&size),
|
||||
"Invalid MeshCore firmware size"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(path).await?.len() == size,
|
||||
"Firmware size mismatch; radio was not written"
|
||||
);
|
||||
let expected = asset
|
||||
.digest
|
||||
.as_deref()
|
||||
.and_then(|value| value.strip_prefix("sha256:"))
|
||||
.context("MeshCore release did not provide a SHA-256 checksum")?;
|
||||
anyhow::ensure!(
|
||||
expected.len() == 64 && expected.bytes().all(|byte| byte.is_ascii_hexdigit()),
|
||||
"Invalid MeshCore release checksum"
|
||||
);
|
||||
let actual = hex::encode(Sha256::digest(tokio::fs::read(path).await?));
|
||||
anyhow::ensure!(
|
||||
actual.eq_ignore_ascii_case(expected),
|
||||
"Firmware checksum mismatch; radio was not written"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_to_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
@@ -722,7 +828,8 @@ async fn download_to_file(
|
||||
}
|
||||
}
|
||||
}
|
||||
file.flush().await.ok();
|
||||
file.flush().await.context("Flushing firmware download")?;
|
||||
file.sync_all().await.context("Saving firmware download")?;
|
||||
tokio::fs::rename(&tmp, dest)
|
||||
.await
|
||||
.context("Finalizing firmware download")?;
|
||||
@@ -773,19 +880,10 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>)
|
||||
/// Building global args separately from subcommand args keeps this correct
|
||||
/// by construction instead of relying on call-site ordering.
|
||||
///
|
||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||
/// fetches the exact same file from the matching upstream esptool release
|
||||
/// tag and installs it alongside the apt package (see the esptool install
|
||||
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||
/// so --no-stub broke our "always erase before write" default outright
|
||||
/// rather than just being slower. Restoring the real stub file is the
|
||||
/// correct fix, not routing around its absence.
|
||||
/// Normal stub-loader mode is required for full-chip erase. The packaged
|
||||
/// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's
|
||||
/// stripped esptool package alone did not provide that resource, so changing
|
||||
/// flags to --no-stub cannot repair this operation.
|
||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||
if let Some(b) = baud {
|
||||
@@ -795,24 +893,96 @@ fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str>
|
||||
args
|
||||
}
|
||||
|
||||
fn esptool_executable() -> Result<PathBuf> {
|
||||
let mut candidates = vec![PathBuf::from("/usr/local/bin/archy-esptool")];
|
||||
if let Some(paths) = std::env::var_os("PATH") {
|
||||
for directory in std::env::split_paths(&paths) {
|
||||
for name in ["esptool", "esptool.py"] {
|
||||
candidates.push(directory.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
executable_from_candidates(candidates)
|
||||
}
|
||||
|
||||
fn executable_from_candidates(candidates: impl IntoIterator<Item = PathBuf>) -> Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
candidates.into_iter().find(|path| {
|
||||
path.is_file() && path.metadata().is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
|
||||
}).ok_or_else(|| anyhow::anyhow!(
|
||||
"Radio flashing tools are missing. Install the complete Archipelago update and retry; the radio has not been changed."
|
||||
))
|
||||
}
|
||||
|
||||
async fn preflight_esptool() -> Result<PathBuf> {
|
||||
let executable = esptool_executable()?;
|
||||
check_esptool(&executable).await?;
|
||||
Ok(executable)
|
||||
}
|
||||
|
||||
async fn check_esptool(executable: &Path) -> Result<()> {
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.arg(
|
||||
if executable
|
||||
.file_name()
|
||||
.is_some_and(|name| name == "archy-esptool")
|
||||
{
|
||||
"--archy-self-test"
|
||||
} else {
|
||||
"version"
|
||||
},
|
||||
)
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(20), command.output())
|
||||
.await
|
||||
.context("Radio flashing tool did not respond; the radio has not been changed")?
|
||||
.context("Radio flashing tool could not start; check its installation and permissions")?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Radio flashing tool self-check failed; reinstall the complete update before retrying"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retryable_flash_error(error: &anyhow::Error) -> bool {
|
||||
if error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let detail = format!("{error:#}").to_lowercase();
|
||||
[
|
||||
"failed to connect",
|
||||
"timed out waiting",
|
||||
"invalid head of packet",
|
||||
"serial data stream stopped",
|
||||
]
|
||||
.iter()
|
||||
.any(|message| detail.contains(message))
|
||||
}
|
||||
|
||||
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
|
||||
let mut cmd = Command::new("esptool");
|
||||
let executable = preflight_esptool().await?;
|
||||
let mut cmd = Command::new(&executable);
|
||||
cmd.args(esptool_global_args(path, None));
|
||||
cmd.args(subcommand);
|
||||
match run_streamed(cmd, None, job).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_err) => {
|
||||
Err(first_err) if retryable_flash_error(&first_err) => {
|
||||
job.push_log(format!(
|
||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||
))
|
||||
.await;
|
||||
let mut retry = Command::new("esptool");
|
||||
let mut retry = Command::new(&executable);
|
||||
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||
retry.args(subcommand);
|
||||
run_streamed(retry, None, job)
|
||||
.await
|
||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,3 +1160,159 @@ async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJ
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod flashing_regression_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_flash_invalidates_only_radio_settings_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join("meshcore-radio-params.json");
|
||||
tokio::fs::write(&marker, b"old settings").await.unwrap();
|
||||
let other = dir.path().join("mesh-config.json");
|
||||
tokio::fs::write(&other, b"preserved").await.unwrap();
|
||||
invalidate_radio_settings_marker(dir.path()).await.unwrap();
|
||||
assert!(!marker.exists());
|
||||
assert_eq!(tokio::fs::read(&other).await.unwrap(), b"preserved");
|
||||
invalidate_radio_settings_marker(dir.path()).await.unwrap();
|
||||
tokio::fs::create_dir(&marker).await.unwrap();
|
||||
assert!(invalidate_radio_settings_marker(dir.path()).await.is_err());
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn meshcore_cache_requires_release_size_and_checksum() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("fixture.bin");
|
||||
tokio::fs::write(&file, b"firmware fixture").await.unwrap();
|
||||
let mut asset = GithubAsset {
|
||||
name: "fixture.bin".into(),
|
||||
browser_download_url: "https://example.invalid/fixture.bin".into(),
|
||||
size: Some(16),
|
||||
digest: Some(format!(
|
||||
"sha256:{}",
|
||||
hex::encode(Sha256::digest(b"firmware fixture"))
|
||||
)),
|
||||
};
|
||||
assert!(verify_meshcore_asset(&file, &asset).await.is_ok());
|
||||
tokio::fs::write(&file, b"tampered fixture").await.unwrap();
|
||||
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
|
||||
tokio::fs::write(&file, b"short").await.unwrap();
|
||||
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
|
||||
tokio::fs::write(&file, b"firmware fixture").await.unwrap();
|
||||
asset.digest = None;
|
||||
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_usb_ids_do_not_select_firmware() {
|
||||
for (vid, pid) in [("10c4", "ea60"), ("303a", "1001")] {
|
||||
let info = DetectedDeviceInfo {
|
||||
path: "/dev/fixture".into(),
|
||||
vid: Some(vid.into()),
|
||||
pid: Some(pid.into()),
|
||||
product: None,
|
||||
manufacturer: None,
|
||||
plugged_at: None,
|
||||
};
|
||||
assert_eq!(resolve_flash_board(&info), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_nonexecutable_and_directory_candidates_are_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("flasher");
|
||||
std::fs::write(&file, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(executable_from_candidates([
|
||||
dir.path().join("absent"),
|
||||
file.clone(),
|
||||
dir.path().to_path_buf()
|
||||
])
|
||||
.is_err());
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert_eq!(executable_from_candidates([file.clone()]).unwrap(), file);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flasher_preflight_reports_missing_interpreter_and_failed_self_check() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("archy-esptool");
|
||||
for script in ["#!/missing/python\n", "#!/bin/sh\nexit 7\n"] {
|
||||
std::fs::write(&file, script).unwrap();
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(check_esptool(&file).await.is_err());
|
||||
}
|
||||
std::fs::write(&file, "#!/bin/sh\n[ \"$1\" = --archy-self-test ]\n").unwrap();
|
||||
assert!(check_esptool(&file).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flash_registration_excludes_other_writers_and_active_probes() {
|
||||
let handle = new_job_handle();
|
||||
let first = FlashJob::new(
|
||||
FlashBoard::HeltecV3,
|
||||
DeviceType::Meshcore,
|
||||
"/dev/fixture".into(),
|
||||
);
|
||||
let second = FlashJob::new(
|
||||
FlashBoard::HeltecV3,
|
||||
DeviceType::Meshcore,
|
||||
"/dev/fixture".into(),
|
||||
);
|
||||
let probe = handle.read().await;
|
||||
assert!(register_flash_job(&handle, &first).await.is_err());
|
||||
drop(probe);
|
||||
let (a, b) = tokio::join!(
|
||||
register_flash_job(&handle, &first),
|
||||
register_flash_job(&handle, &second)
|
||||
);
|
||||
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
|
||||
handle.read().await.as_ref().unwrap().finish().await;
|
||||
let next = FlashJob::new(
|
||||
FlashBoard::HeltecV3,
|
||||
DeviceType::Meshcore,
|
||||
"/dev/fixture".into(),
|
||||
);
|
||||
assert!(register_flash_job(&handle, &next).await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_only_known_serial_transport_failures() {
|
||||
for kind in [
|
||||
std::io::ErrorKind::NotFound,
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
] {
|
||||
assert!(!retryable_flash_error(
|
||||
&anyhow::Error::from(std::io::Error::from(kind))
|
||||
.context("Failed to start subprocess")
|
||||
));
|
||||
}
|
||||
for message in [
|
||||
"Wrong chip",
|
||||
"Invalid image",
|
||||
"module missing",
|
||||
"permission denied",
|
||||
"port is busy",
|
||||
] {
|
||||
assert!(!retryable_flash_error(&anyhow::anyhow!(message)));
|
||||
}
|
||||
assert!(retryable_flash_error(&anyhow::anyhow!(
|
||||
"Failed to connect to ESP32-S3: timed out waiting for packet header"
|
||||
)));
|
||||
assert_eq!(
|
||||
esptool_global_args("/dev/fixture", Some("115200")),
|
||||
[
|
||||
"--chip",
|
||||
"esp32s3",
|
||||
"--port",
|
||||
"/dev/fixture",
|
||||
"--baud",
|
||||
"115200"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,6 +1092,14 @@ impl MeshService {
|
||||
let (new_tx, new_rx) = tokio::sync::mpsc::channel(32);
|
||||
*self.state.cmd_tx.write().await = new_tx;
|
||||
self.cmd_rx = Some(new_rx);
|
||||
{
|
||||
let mut status = self.state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
status.peer_count = 0;
|
||||
}
|
||||
info!("Mesh service stopped");
|
||||
}
|
||||
|
||||
@@ -2321,7 +2329,10 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
let was_enabled = self.config.enabled;
|
||||
let listener_running = self
|
||||
.listener_handle
|
||||
.as_ref()
|
||||
.is_some_and(|handle| !handle.is_finished());
|
||||
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||
self.config = config.clone();
|
||||
|
||||
@@ -2335,10 +2346,13 @@ impl MeshService {
|
||||
.unwrap_or_else(|| "archipelago".to_string());
|
||||
}
|
||||
|
||||
// If enabled state changed, start/stop the listener
|
||||
if config.enabled && !was_enabled {
|
||||
// Reconcile desired state with the actual task, not the old enabled
|
||||
// flag. A failed flash can stop the task while keeping enabled=true;
|
||||
// explicitly reconnecting with the same settings must restart it.
|
||||
if config.enabled && !listener_running {
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
} else if !config.enabled && was_enabled {
|
||||
} else if !config.enabled {
|
||||
self.stop().await;
|
||||
// Clear connected state
|
||||
let mut status = self.state.status.write().await;
|
||||
@@ -2347,7 +2361,7 @@ impl MeshService {
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
status.peer_count = 0;
|
||||
} else if config.enabled && was_enabled && needs_session_restart {
|
||||
} else if needs_session_restart {
|
||||
info!("Mesh session config changed — restarting listener to apply");
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
@@ -2537,6 +2551,41 @@ mod tests {
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_with_unchanged_enabled_config_restarts_stopped_listener() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = SigningKey::from_bytes(&[7; 32]);
|
||||
let public = hex::encode(key.verifying_key().to_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&public).unwrap();
|
||||
let config = MeshConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let mut service = MeshService::new(dir.path(), &key, &did, &public)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(service.listener_handle.is_none());
|
||||
service.configure(config.clone()).await.unwrap();
|
||||
assert!(service.listener_handle.is_some());
|
||||
service.stop().await;
|
||||
assert!(service.config.enabled);
|
||||
service.configure(config.clone()).await.unwrap();
|
||||
assert!(service.listener_handle.is_some());
|
||||
service.stop().await;
|
||||
service.listener_handle = Some(tokio::spawn(async {}));
|
||||
tokio::task::yield_now().await;
|
||||
service.configure(config).await.unwrap();
|
||||
assert!(!service.listener_handle.as_ref().unwrap().is_finished());
|
||||
service.stop().await;
|
||||
let disabled = MeshConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
service.configure(disabled).await.unwrap();
|
||||
assert!(service.listener_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_config_change_detection() {
|
||||
let base = MeshConfig::default();
|
||||
|
||||
@@ -378,6 +378,19 @@ fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
// Official companion v3+ binary layout: protocol, half-capacity,
|
||||
// channels, PIN (4), build date (12), model (40), version (20).
|
||||
// Verified against companion-v1.17.1 MyMesh.cpp and Heltec V3 readback.
|
||||
if data
|
||||
.first()
|
||||
.is_some_and(|version| (3..=31).contains(version))
|
||||
{
|
||||
anyhow::ensure!(data.len() >= 79, "Truncated MeshCore device info");
|
||||
return Ok((
|
||||
decode_mesh_name(&data[59..79], "unknown"),
|
||||
u16::from(data[1]) * 2,
|
||||
));
|
||||
}
|
||||
// Device info format varies by firmware version.
|
||||
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
||||
if data.is_empty() {
|
||||
@@ -404,6 +417,15 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
anyhow::bail!("Self info response too short: {} bytes", data.len());
|
||||
}
|
||||
|
||||
// Current companions send type/power/max-power, public key (32),
|
||||
// position (8), four preference bytes, RF parameters (10), then name.
|
||||
if data.len() >= 57 {
|
||||
let node_id = u32::from_le_bytes(data[3..7].try_into().unwrap());
|
||||
return Ok((
|
||||
node_id,
|
||||
decode_mesh_name(&data[57..], &format!("node-{node_id:08x}")),
|
||||
));
|
||||
}
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||
@@ -966,4 +988,24 @@ mod tests {
|
||||
fn test_parse_self_info_too_short() {
|
||||
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_companion_binary_metadata_is_not_a_name_or_version() {
|
||||
let mut info = vec![0u8; 81];
|
||||
info[0] = 13;
|
||||
info[1] = 150;
|
||||
info[19..28].copy_from_slice(b"Heltec V3");
|
||||
info[59..74].copy_from_slice(b"v1.17.1-d929643");
|
||||
assert_eq!(
|
||||
parse_device_info(&info).unwrap(),
|
||||
("v1.17.1-d929643".into(), 300)
|
||||
);
|
||||
assert!(parse_device_info(&info[..78]).is_err());
|
||||
let mut own = vec![0u8; 57];
|
||||
own[0..3].copy_from_slice(&[1, 22, 22]);
|
||||
own[3..7].copy_from_slice(&42u32.to_le_bytes());
|
||||
own[47..51].copy_from_slice(&869618u32.to_le_bytes());
|
||||
own.extend_from_slice(b"My radio");
|
||||
assert_eq!(parse_self_info(&own).unwrap(), (42, "My radio".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,19 @@ fn terminate_group(child: &Child) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Own the daemon during its asynchronous handshake too. Cancellation drops
|
||||
/// the future without executing an error branch; a bare Child would survive
|
||||
/// and keep the serial port open even though the listener had stopped.
|
||||
struct StartingDaemon(Option<Child>);
|
||||
|
||||
impl Drop for StartingDaemon {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.0.as_ref() {
|
||||
terminate_group(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One peer learned via an RNS announce (LXMF delivery destination).
|
||||
#[derive(Clone)]
|
||||
struct ReticulumPeer {
|
||||
@@ -517,10 +530,10 @@ impl ReticulumLink {
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
|
||||
let mut starting = StartingDaemon(Some(child));
|
||||
|
||||
// Wait for the socket to appear, then for the daemon's "ready" event.
|
||||
// Runs as a block so every failure path tears the just-spawned daemon
|
||||
// group down via `terminate_group` (the child has no `kill_on_drop`).
|
||||
// StartingDaemon tears the group down on errors AND cancellation.
|
||||
let init = async {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||
let stream = loop {
|
||||
@@ -560,20 +573,17 @@ impl ReticulumLink {
|
||||
dest_hash = %dest_hash_hex,
|
||||
"Reticulum daemon ready"
|
||||
);
|
||||
Ok((write_half, reader, dest_hash, display_name))
|
||||
};
|
||||
let (write_half, reader, dest_hash, display_name) = match init.await {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => {
|
||||
terminate_group(&child);
|
||||
return Err(e);
|
||||
}
|
||||
Ok::<_, anyhow::Error>((write_half, reader, dest_hash, display_name))
|
||||
};
|
||||
let (write_half, reader, dest_hash, display_name) = init.await?;
|
||||
|
||||
let mut link = Self {
|
||||
device_path: label,
|
||||
socket_path,
|
||||
child,
|
||||
child: starting
|
||||
.0
|
||||
.take()
|
||||
.expect("starting daemon is owned until ready"),
|
||||
writer: write_half,
|
||||
reader,
|
||||
dest_hash,
|
||||
@@ -1557,6 +1567,33 @@ impl Drop for ReticulumLink {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_daemon_handshake_terminates_child() {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let child = Command::new("sleep")
|
||||
.arg("60")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id().unwrap();
|
||||
let _starting = StartingDaemon(Some(child));
|
||||
started.send(pid).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let pid = ready.await.unwrap();
|
||||
assert_eq!(unsafe { libc::kill(pid as i32, 0) }, 0);
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled handshake left its child alive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announced_name_precedence() {
|
||||
// Daemon-decoded LXMF name always wins.
|
||||
|
||||
@@ -146,12 +146,16 @@ impl MeshcoreDevice {
|
||||
}
|
||||
}
|
||||
|
||||
let info = DeviceInfo {
|
||||
firmware_version: name.clone(),
|
||||
let mut info = DeviceInfo {
|
||||
firmware_version: "unknown".to_string(),
|
||||
node_id,
|
||||
max_contacts: 100,
|
||||
device_type: super::types::DeviceType::Meshcore,
|
||||
};
|
||||
if let Some((version, capacity)) = self.query_device_info().await {
|
||||
info.firmware_version = version;
|
||||
info.max_contacts = capacity;
|
||||
}
|
||||
self.device_info = Some(info.clone());
|
||||
|
||||
info!("Meshcore initialization complete on {}", self.device_path);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Compatibility and hostile-relay regression tests for the 0.44 security update.
|
||||
//! Loopback sockets run only through scripts/test-backend-isolated.sh.
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn native_signer_encryption_remains_compatible_and_rejects_hostile_payloads() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let message = "Archipelago signing compatibility — \u{1f30a}";
|
||||
let encrypted = nip44::encrypt(
|
||||
alice.secret_key(),
|
||||
&bob.public_key(),
|
||||
message,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
nip44::decrypt(bob.secret_key(), &alice.public_key(), &encrypted).unwrap(),
|
||||
message
|
||||
);
|
||||
let encrypted = nip04::encrypt(alice.secret_key(), &bob.public_key(), message).unwrap();
|
||||
assert_eq!(
|
||||
nip04::decrypt(bob.secret_key(), &alice.public_key(), encrypted).unwrap(),
|
||||
message
|
||||
);
|
||||
// Valid v2 prefix followed by a payload exceeding the codec's maximum.
|
||||
// This must fail at the size gate, before allocation/decoding/HMAC work.
|
||||
let oversized = format!("AgAA{}", "A".repeat(100_000));
|
||||
assert!(matches!(
|
||||
nip44::decrypt(bob.secret_key(), &alice.public_key(), oversized),
|
||||
Err(nip44::Error::MessageTooLong)
|
||||
));
|
||||
for malformed in ["", "Ag==", "not base64!", "?iv=", "YQ==?iv=YQ=="] {
|
||||
assert!(nip04::decrypt(bob.secret_key(), &alice.public_key(), malformed).is_err());
|
||||
assert!(nip44::decrypt(bob.secret_key(), &alice.public_key(), malformed).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_cannot_substitute_forged_fields_for_a_known_event_id() {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
let test = async {
|
||||
let author = Keys::generate();
|
||||
let known = EventBuilder::text_note("already verified")
|
||||
.sign_with_keys(&author)
|
||||
.unwrap();
|
||||
let valid = EventBuilder::text_note("new legitimate message")
|
||||
.sign_with_keys(&author)
|
||||
.unwrap();
|
||||
let mut forged_content = known.clone();
|
||||
forged_content.content = "forged content".into();
|
||||
let mut forged_author = known.clone();
|
||||
forged_author.pubkey = Keys::generate().public_key();
|
||||
let mut forged_signature = known.clone();
|
||||
forged_signature.sig = valid.sig;
|
||||
for forged in [&forged_content, &forged_author, &forged_signature] {
|
||||
assert!(forged.verify().is_err());
|
||||
}
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://{}", listener.local_addr().unwrap());
|
||||
let expected_id = valid.id;
|
||||
let relay = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut socket = accept_async(socket).await.unwrap();
|
||||
while let Some(message) = socket.next().await {
|
||||
let text = match message.unwrap() {
|
||||
Message::Text(text) => text,
|
||||
Message::Ping(payload) => {
|
||||
socket.send(Message::Pong(payload)).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
let message: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
if message[0] != "REQ" {
|
||||
continue;
|
||||
}
|
||||
let subscription = message[1].as_str().unwrap();
|
||||
for event in [&forged_content, &forged_author, &forged_signature, &valid] {
|
||||
socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!(["EVENT", subscription, event]).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!(["EOSE", subscription]).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let client = Client::new(Keys::generate());
|
||||
client.database().save_event(&known).await.unwrap();
|
||||
client.add_relay(&url).await.unwrap();
|
||||
client
|
||||
.try_connect_relay(&url, std::time::Duration::from_secs(3))
|
||||
.await
|
||||
.unwrap();
|
||||
let events = client
|
||||
.fetch_events(
|
||||
Filter::new().kind(Kind::TextNote),
|
||||
std::time::Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"forged events must never reach SDK consumers"
|
||||
);
|
||||
assert_eq!(events.iter().next().unwrap().id, expected_id);
|
||||
client.disconnect().await;
|
||||
relay.abort();
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(15), test)
|
||||
.await
|
||||
.expect("local relay test timed out");
|
||||
}
|
||||
@@ -83,6 +83,8 @@ impl EndpointRateLimiter {
|
||||
limits.insert("wallet.send".to_string(), (5usize, 300u64));
|
||||
limits.insert("wallet.ecash-send".to_string(), (10, 300));
|
||||
limits.insert("lnd.sendcoins".to_string(), (5, 300));
|
||||
limits.insert("lnd.bump-submit".to_string(), (5, 300));
|
||||
limits.insert("lnd.bump-quote".to_string(), (30, 60));
|
||||
limits.insert("lnd.payinvoice".to_string(), (10, 300));
|
||||
limits.insert("lnd.openchannel".to_string(), (3, 300));
|
||||
limits.insert("lnd.closechannel".to_string(), (3, 300));
|
||||
|
||||
+223
-19
@@ -1475,6 +1475,48 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
|| path.starts_with("/dwn/")
|
||||
}
|
||||
|
||||
/// The ordinary API listener is a management surface even when contacted
|
||||
/// directly, without host nginx. Peer traffic has its own path-restricted
|
||||
/// listener and retains its existing cryptographic authentication.
|
||||
fn management_peer_is_private(address: std::net::IpAddr) -> bool {
|
||||
match address {
|
||||
std::net::IpAddr::V4(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]))
|
||||
}
|
||||
std::net::IpAddr::V6(ip) => {
|
||||
if let Some(mapped) = ip.to_ipv4_mapped() {
|
||||
management_peer_is_private(std::net::IpAddr::V4(mapped))
|
||||
} else {
|
||||
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_surface_allowed(
|
||||
peer_only: bool,
|
||||
peer: std::net::IpAddr,
|
||||
request: &hyper::Request<hyper::Body>,
|
||||
) -> bool {
|
||||
if peer_only {
|
||||
return is_peer_allowed_path(request.uri().path());
|
||||
}
|
||||
// Keep purpose-built content/peer HTTP endpoints reachable with their
|
||||
// existing handler-level checks. The general RPC dispatcher is a management
|
||||
// surface here; only the dedicated peer listener retains public peer RPC.
|
||||
if request.uri().path() != "/rpc/v1" && is_peer_allowed_path(request.uri().path()) {
|
||||
return true;
|
||||
}
|
||||
management_peer_is_private(peer)
|
||||
&& !request
|
||||
.headers()
|
||||
.get("x-archipelago-public-ingress")
|
||||
.is_some_and(|value| !value.as_bytes().is_empty())
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
handler: Arc<ApiHandler>,
|
||||
listener: TcpListener,
|
||||
@@ -1552,7 +1594,7 @@ async fn accept_loop(
|
||||
// forwarded headers on loopback (nginx) connections.
|
||||
req.extensions_mut()
|
||||
.insert(crate::api::rpc::PeerAddr(peer_addr));
|
||||
if peer_only && !is_peer_allowed_path(req.uri().path()) {
|
||||
if !request_surface_allowed(peer_only, peer_addr.ip(), &req) {
|
||||
let resp = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::empty())
|
||||
@@ -1765,12 +1807,17 @@ fn merge_preserving_transitional(
|
||||
};
|
||||
|
||||
crate::data_model::PackageDataEntry {
|
||||
state,
|
||||
state: state.clone(),
|
||||
// install_progress and uninstall_stage are also owned by the
|
||||
// initiating op (same reason as state) — keep them.
|
||||
install_progress: existing.install_progress.clone(),
|
||||
uninstall_stage: existing.uninstall_stage.clone(),
|
||||
// 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(),
|
||||
exit_code: fresh.exit_code,
|
||||
static_files: fresh.static_files.clone(),
|
||||
@@ -1809,7 +1856,10 @@ async fn scan_and_update_packages(
|
||||
absence_tracker: &mut HashMap<String, u32>,
|
||||
transitional_since: &mut HashMap<String, Instant>,
|
||||
) -> 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;
|
||||
for (id, pkg) in packages.iter_mut() {
|
||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||
@@ -1870,11 +1920,14 @@ async fn scan_and_update_packages(
|
||||
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
||||
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
||||
if tor_changed || update_changed {
|
||||
let mut data = current_data;
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.updated = update_available;
|
||||
state.update_data(data).await;
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address =
|
||||
tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.updated = update_available;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1899,6 +1952,13 @@ async fn scan_and_update_packages(
|
||||
// died without cleanup and let the scan override it.
|
||||
let now = Instant::now();
|
||||
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);
|
||||
let existing = merged.get(id);
|
||||
let overwrite = match existing {
|
||||
@@ -2054,22 +2114,40 @@ async fn scan_and_update_packages(
|
||||
}
|
||||
|
||||
if changed || tor_changed || first_scan || update_changed {
|
||||
let mut data = current_data;
|
||||
data.package_data = merged;
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.containers_scanned = true;
|
||||
data.server_info.status_info.updated = update_available;
|
||||
state.update_data(data).await;
|
||||
debug!(
|
||||
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
|
||||
changed, tor_changed, first_scan, update_changed
|
||||
);
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
// A lifecycle operation may have started/finished while this scan
|
||||
// awaited probes or disk I/O. Never overwrite that newer entry or
|
||||
// resurrect one that an uninstall removed in the meantime.
|
||||
apply_scanned_packages(&mut data.package_data, ¤t_data.package_data, &merged);
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.containers_scanned = true;
|
||||
data.server_info.status_info.updated = update_available;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
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(
|
||||
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
) {
|
||||
@@ -2268,6 +2346,7 @@ mod merge_tests {
|
||||
|
||||
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state,
|
||||
health: health.map(|s| s.to_string()),
|
||||
exit_code: None,
|
||||
@@ -2280,6 +2359,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]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
@@ -2452,3 +2562,97 @@ mod merge_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod management_surface_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_api_listener_rejects_management_despite_forged_headers() {
|
||||
for peer in [
|
||||
"198.18.0.2",
|
||||
"2001:db8::2",
|
||||
"::ffff:198.18.0.2",
|
||||
"100.63.255.255",
|
||||
"100.128.0.1",
|
||||
] {
|
||||
for path in ["/", "/login", "/assets/index.js", "/rpc/v1", "/ws"] {
|
||||
for method in ["GET", "POST"] {
|
||||
let request = hyper::Request::builder()
|
||||
.uri(path)
|
||||
.method(method)
|
||||
.header("host", "127.0.0.1")
|
||||
.header("x-forwarded-for", "127.0.0.1")
|
||||
.header("x-real-ip", "192.168.1.10")
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!request_surface_allowed(false, peer.parse().unwrap(), &request),
|
||||
"{peer} {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_management_and_restricted_peer_transport_remain_available() {
|
||||
let mut request = hyper::Request::builder()
|
||||
.uri("/rpc/v1")
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap();
|
||||
for peer in [
|
||||
"127.0.0.1",
|
||||
"10.0.0.2",
|
||||
"172.16.0.2",
|
||||
"192.168.1.2",
|
||||
"169.254.1.2",
|
||||
"100.64.0.1",
|
||||
"100.127.255.254",
|
||||
"::1",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"::ffff:192.168.1.2",
|
||||
] {
|
||||
assert!(request_surface_allowed(
|
||||
false,
|
||||
peer.parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-archipelago-public-ingress", "1".parse().unwrap());
|
||||
assert!(!request_surface_allowed(
|
||||
false,
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
// The dedicated peer listener retains its existing signed RPC contract.
|
||||
assert!(request_surface_allowed(
|
||||
true,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
for path in [
|
||||
"/content",
|
||||
"/content/fixture/invoice",
|
||||
"/blob/fixture",
|
||||
"/dwn/health",
|
||||
"/archipelago/node-message",
|
||||
] {
|
||||
*request.uri_mut() = path.parse().unwrap();
|
||||
assert!(request_surface_allowed(
|
||||
false,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
*request.uri_mut() = "/login".parse().unwrap();
|
||||
assert!(!request_surface_allowed(
|
||||
true,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||
//! Missing preference preserves the existing disk-based automatic selection.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct BitcoinStorage {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||
let dir = data_dir.join("settings");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("bitcoin-storage.json");
|
||||
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||
tokio::fs::rename(temporary, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), true).await.unwrap();
|
||||
assert!(load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), false).await.unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), true).await.unwrap();
|
||||
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -54,6 +54,21 @@ impl StateManager {
|
||||
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
|
||||
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
||||
let (data, rev) = self.get_snapshot().await;
|
||||
@@ -190,3 +205,29 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -2029,6 +2059,25 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
let backup_binary = backup_dir.join("archipelago");
|
||||
if backup_binary.exists() {
|
||||
// The restored frontend can contain a pre-guard runtime template. An
|
||||
// older binary copies that template verbatim on startup, undoing live
|
||||
// containment. Protect it before permitting the binary downgrade.
|
||||
let template = "/opt/archipelago/web-ui/archipelago-runtime/image-recipe/configs/nginx-archipelago.conf";
|
||||
if Path::new(template).exists() {
|
||||
let protected = host_sudo(&[
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
"--protect-template",
|
||||
template,
|
||||
])
|
||||
.await
|
||||
.context("protect nginx runtime template before rollback")?;
|
||||
anyhow::ensure!(
|
||||
protected.success(),
|
||||
"unsafe nginx rollback template; previous binary not restored"
|
||||
);
|
||||
}
|
||||
// Same two namespace gotchas as apply_update()'s binary swap:
|
||||
// `cp` straight onto the running binary is O_TRUNC and fails
|
||||
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
||||
@@ -2159,20 +2208,25 @@ async fn apply_per_app_auto_updates(
|
||||
}
|
||||
}
|
||||
|
||||
/// After a catalog refresh that changed the cached bytes, rebuild the
|
||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
||||
/// effect now instead of at the next service restart.
|
||||
async fn reload_manifests_if_changed(
|
||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||
/// may have been written before a previous reload failed. Auto-updates only run
|
||||
/// when the catalog and the orchestrator's manifests are ready together.
|
||||
async fn reload_catalog_manifests(
|
||||
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||
) {
|
||||
if !refresh.changed {
|
||||
return;
|
||||
}
|
||||
let Some(orch) = orchestrator else { return };
|
||||
) -> bool {
|
||||
let Some(orch) = orchestrator else {
|
||||
return false;
|
||||
};
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
||||
Ok(n) => {
|
||||
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,7 +2242,9 @@ pub async fn run_update_scheduler(
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Ok(refresh) => {
|
||||
reload_catalog_manifests(refresh, &orchestrator).await;
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
@@ -2204,14 +2260,22 @@ pub async fn run_update_scheduler(
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||
// catalog-shipped manifest fixes apply without a service restart.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
|
||||
}
|
||||
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Per-app auto-update-to-latest (multi-version support). Runs every tick
|
||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||
// may still show cached badges but must not trigger container changes.
|
||||
if catalog_ready {
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
@@ -2609,6 +2673,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_newer() {
|
||||
assert!(is_newer("1.9.0-alpha", "1.8.22-alpha"));
|
||||
assert!(!is_newer("1.9.0-alpha", "1.9.0-alpha"));
|
||||
assert!(is_newer("1.7.19-alpha", "1.7.18-alpha"));
|
||||
assert!(is_newer("1.8.0-alpha", "1.7.99-alpha"));
|
||||
assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user