Compare commits
37
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 |
+7
-1
@@ -4,7 +4,13 @@
|
|||||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||||
!neode-ui/
|
!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/
|
!demo/
|
||||||
|
|
||||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'neode-ui/**'
|
- 'neode-ui/**'
|
||||||
|
- 'aiui/**'
|
||||||
|
- 'scripts/build-aiui.sh'
|
||||||
|
- '.dockerignore'
|
||||||
- 'docker-compose.demo.yml'
|
- 'docker-compose.demo.yml'
|
||||||
- '.gitea/workflows/demo-images.yml'
|
- '.gitea/workflows/demo-images.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -65,10 +68,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_DEMO=1
|
VITE_DEMO=1
|
||||||
|
SOURCE_REVISION=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||||
|
|
||||||
- name: Trigger Portainer redeploy
|
- 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 }}"
|
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'neode-ui/**'
|
- 'neode-ui/**'
|
||||||
|
- 'aiui/**'
|
||||||
|
- 'scripts/build-aiui.sh'
|
||||||
|
- '.dockerignore'
|
||||||
- 'docker-compose.demo.yml'
|
- 'docker-compose.demo.yml'
|
||||||
- '.github/workflows/demo-images.yml'
|
- '.github/workflows/demo-images.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -65,10 +68,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_DEMO=1
|
VITE_DEMO=1
|
||||||
|
SOURCE_REVISION=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||||
|
|
||||||
- name: Trigger Portainer redeploy
|
- 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 }}"
|
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||||
|
|||||||
@@ -29,3 +29,45 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
|||||||
tests still reached real service commands. The runner isolates wallet data,
|
tests still reached real service commands. The runner isolates wallet data,
|
||||||
service buses, container storage, networking, and process IDs. Compilation with
|
service buses, container storage, networking, and process IDs. Compilation with
|
||||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
`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"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 52
|
versionCode = 54
|
||||||
versionName = "0.5.32"
|
versionName = "0.5.34"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -142,6 +142,9 @@ tasks.matching {
|
|||||||
}.configureEach { dependsOn("buildRustArm64") }
|
}.configureEach { dependsOn("buildRustArm64") }
|
||||||
|
|
||||||
dependencies {
|
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")
|
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||||
implementation(composeBom)
|
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.
|
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||||
meshFallbackUrl: String? = null,
|
meshFallbackUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val fullscreen = rememberWebViewFullscreen()
|
||||||
|
val downloads = rememberWebViewDownloads()
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||||
// loader; later navigations keep just the slim top progress bar.
|
// loader; later navigations keep just the slim top progress bar.
|
||||||
@@ -913,6 +915,7 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
setDownloadListener(downloads)
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
@@ -1181,6 +1184,12 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
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) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
@@ -1546,6 +1555,8 @@ private fun InAppBrowser(
|
|||||||
appName: String? = null,
|
appName: String? = null,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val fullscreen = rememberWebViewFullscreen()
|
||||||
|
val downloads = rememberWebViewDownloads()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||||
@@ -1643,6 +1654,7 @@ private fun InAppBrowser(
|
|||||||
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
setDownloadListener(downloads)
|
||||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||||
// served over plain HTTP too — same dead-clipboard trap.
|
// served over plain HTTP too — same dead-clipboard trap.
|
||||||
addClipboardBridge()
|
addClipboardBridge()
|
||||||
@@ -1662,6 +1674,12 @@ private fun InAppBrowser(
|
|||||||
)
|
)
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
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) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
progress = newProgress
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
|
||||||
|
|||||||
+38
-1
@@ -64,12 +64,49 @@ App submissions must:
|
|||||||
|
|
||||||
## Pull requests
|
## 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.
|
2. Explain what changed, why it changed, and how it was verified.
|
||||||
3. Include screenshots for UI changes.
|
3. Include screenshots for UI changes.
|
||||||
4. Link relevant issues or docs.
|
4. Link relevant issues or docs.
|
||||||
5. Keep generated catalog changes in sync with manifest changes.
|
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:
|
Suggested commit format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Archipelago
|
# 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.
|
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||||
|
|
||||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||||
|
|||||||
@@ -436,13 +436,13 @@
|
|||||||
{
|
{
|
||||||
"id": "nginx-proxy-manager",
|
"id": "nginx-proxy-manager",
|
||||||
"title": "Nginx Proxy Manager",
|
"title": "Nginx Proxy Manager",
|
||||||
"version": "2.12.1",
|
"version": "2.14.0",
|
||||||
"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).",
|
"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",
|
"icon": "/assets/img/app-icons/nginx.svg",
|
||||||
"author": "Nginx Proxy Manager",
|
"author": "Nginx Proxy Manager",
|
||||||
"category": "networking",
|
"category": "networking",
|
||||||
"tier": "optional",
|
"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"
|
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -648,9 +648,9 @@
|
|||||||
{
|
{
|
||||||
"id": "angor-indexer",
|
"id": "angor-indexer",
|
||||||
"title": "Angor Indexer",
|
"title": "Angor Indexer",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
"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.1",
|
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
|
||||||
"author": "Angor / Archipelago",
|
"author": "Angor / Archipelago",
|
||||||
"requires": [
|
"requires": [
|
||||||
"Mempool API",
|
"Mempool API",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Angor Indexer
|
# Angor Indexer
|
||||||
|
|
||||||
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
|
Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
|
||||||
backend and Electrum index instead of creating a second blockchain database.
|
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
|
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
|
||||||
node must show the existing archival-node requirement; it must never silently
|
node must show the existing archival-node requirement; it must never silently
|
||||||
unprune or replace its Bitcoin data.
|
unprune or replace its Bitcoin data.
|
||||||
@@ -32,12 +33,45 @@ Install **Angor Relay** separately to host project metadata locally, then add
|
|||||||
clients. Its storage and configuration are separate from the node's internal
|
clients. Its storage and configuration are separate from the node's internal
|
||||||
relay; installing or uninstalling it does not change the internal relay.
|
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
|
## Packaging
|
||||||
|
|
||||||
Build the pinned image with:
|
Build the pinned image with:
|
||||||
|
|
||||||
```
|
```
|
||||||
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container
|
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.
|
The image runs as UID 101 with a read-only root filesystem and no capabilities.
|
||||||
@@ -55,3 +89,21 @@ address query is indexed at the latest Bitcoin tip.
|
|||||||
Install Mempool Explorer first. The declarative `install_prerequisites` check
|
Install Mempool Explorer first. The declarative `install_prerequisites` check
|
||||||
refuses a new adapter installation if its Mempool API component is absent, before
|
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.
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ http {
|
|||||||
zone mempool_backend 64k;
|
zone mempool_backend 64k;
|
||||||
server mempool-api:8999 resolve;
|
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 {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
client_max_body_size 4m;
|
client_max_body_size 4m;
|
||||||
@@ -23,7 +31,8 @@ http {
|
|||||||
proxy_send_timeout 30s;
|
proxy_send_timeout 30s;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection $angor_connection_upgrade;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Authorization "";
|
proxy_set_header Authorization "";
|
||||||
proxy_set_header Cookie "";
|
proxy_set_header Cookie "";
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
@@ -35,10 +44,6 @@ http {
|
|||||||
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
|
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
|
||||||
# surface too, without doubling already-versioned Angor URLs.
|
# surface too, without doubling already-versioned Angor URLs.
|
||||||
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
|
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
|
||||||
location = / {
|
|
||||||
default_type application/json;
|
|
||||||
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
|
|
||||||
}
|
|
||||||
# Readiness checks the indexing backend, not this gateway's process.
|
# Readiness checks the indexing backend, not this gateway's process.
|
||||||
location = /health {
|
location = /health {
|
||||||
limit_except GET { deny all; }
|
limit_except GET { deny all; }
|
||||||
@@ -54,10 +59,23 @@ http {
|
|||||||
limit_except GET POST { deny all; }
|
limit_except GET POST { deny all; }
|
||||||
proxy_pass http://mempool_backend;
|
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/ {
|
location /api/ {
|
||||||
limit_except GET { deny all; }
|
limit_except GET { deny all; }
|
||||||
proxy_pass http://mempool_backend;
|
proxy_pass http://mempool_backend;
|
||||||
}
|
}
|
||||||
location / { return 404; }
|
# 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
app:
|
app:
|
||||||
id: angor-indexer
|
id: angor-indexer
|
||||||
name: Angor Indexer
|
name: Angor Indexer
|
||||||
version: 1.0.1
|
version: 1.0.2
|
||||||
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
|
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
|
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
|
address as the custom indexer in Angor settings. A relay is optional and installed
|
||||||
separately.
|
separately.
|
||||||
category: money
|
category: money
|
||||||
install_prerequisites:
|
install_prerequisites:
|
||||||
|
- mempool
|
||||||
- mempool-api
|
- mempool-api
|
||||||
upstream:
|
upstream:
|
||||||
kind: github
|
kind: github
|
||||||
repo: block-core/angor
|
repo: block-core/angor
|
||||||
container:
|
container:
|
||||||
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1
|
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- app_id: mempool
|
||||||
|
version: '>=3.0.0'
|
||||||
- app_id: mempool-api
|
- app_id: mempool-api
|
||||||
version: '>=3.0.0'
|
version: '>=3.0.0'
|
||||||
- bitcoin:archival
|
- bitcoin:archival
|
||||||
@@ -40,8 +44,8 @@ app:
|
|||||||
interfaces:
|
interfaces:
|
||||||
main:
|
main:
|
||||||
name: Angor Indexer API
|
name: Angor Indexer API
|
||||||
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
|
description: Use this origin as Angor’s custom mainnet indexer URL, or open it
|
||||||
required for browser clients.
|
to view the existing Mempool explorer. HTTPS is required for browser clients.
|
||||||
type: api
|
type: api
|
||||||
port: 8998
|
port: 8998
|
||||||
protocol: http
|
protocol: http
|
||||||
@@ -63,6 +67,7 @@ app:
|
|||||||
repo: https://github.com/block-core/angor
|
repo: https://github.com/block-core/angor
|
||||||
features:
|
features:
|
||||||
- Angor mainnet API
|
- Angor mainnet API
|
||||||
|
- Mempool explorer on the same origin
|
||||||
- Reuses existing Mempool indexing
|
- Reuses existing Mempool indexing
|
||||||
- No separate blockchain database
|
- No separate blockchain database
|
||||||
- Optional independent relay
|
- Optional independent relay
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ this is a public relay, not a private messaging archive. It mounts only
|
|||||||
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
|
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
|
||||||
It never opens, reconfigures or shares the node's internal strfry database.
|
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.
|
The configuration is seeded only when absent, preserving operator changes.
|
||||||
Stop the service before making a consistent backup of its event database.
|
Stop the service before making a consistent backup of its event database.
|
||||||
Ordinary start/restart/recreation preserves both mounts. Use the standard app
|
Ordinary start/restart/recreation preserves both mounts. Use the standard app
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
app:
|
app:
|
||||||
id: nginx-proxy-manager
|
id: nginx-proxy-manager
|
||||||
name: Nginx Proxy Manager
|
name: Nginx Proxy Manager
|
||||||
version: 2.12.1
|
version: 2.14.0
|
||||||
upstream:
|
upstream:
|
||||||
kind: github
|
kind: github
|
||||||
repo: NginxProxyManager/nginx-proxy-manager
|
repo: NginxProxyManager/nginx-proxy-manager
|
||||||
description: >-
|
description: >-
|
||||||
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
||||||
On a node, this manages its admin UI and upstream configuration — the
|
The node's public web server forwards configured domains through this
|
||||||
proxy's own :80/:443 listeners are not published (the node's web server
|
service, preserving its access lists, certificates and custom routes.
|
||||||
owns those ports).
|
backup_before_runtime_change: true
|
||||||
|
|
||||||
container:
|
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
|
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:
|
dependencies:
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
@@ -49,6 +52,17 @@ app:
|
|||||||
Nginx Proxy Manager enforces its own admin account on every page;
|
Nginx Proxy Manager enforces its own admin account on every page;
|
||||||
the initial setup wizard also has to answer before any account exists.
|
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:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/nginx-proxy-manager
|
source: /var/lib/archipelago/nginx-proxy-manager
|
||||||
|
|||||||
Generated
+5
-5
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.22-alpha"
|
version = "1.9.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
@@ -3636,9 +3636,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.44.2"
|
version = "0.44.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -3681,9 +3681,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-relay-pool"
|
name = "nostr-relay-pool"
|
||||||
version = "0.44.0"
|
version = "0.44.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
|
checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"async-wsocket",
|
"async-wsocket",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.22-alpha"
|
version = "1.9.0-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ impl ApiHandler {
|
|||||||
let invoice_hash = headers
|
let invoice_hash = headers
|
||||||
.get("x-invoice-hash")
|
.get("x-invoice-hash")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_ascii_lowercase())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
headers
|
headers
|
||||||
.get("x-onchain-address")
|
.get("x-onchain-address")
|
||||||
@@ -98,6 +98,46 @@ impl ApiHandler {
|
|||||||
None => false,
|
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
|
// Parse Range header for streaming support
|
||||||
let range = headers
|
let range = headers
|
||||||
.get("range")
|
.get("range")
|
||||||
@@ -249,7 +289,13 @@ impl ApiHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
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!({
|
let body = serde_json::json!({
|
||||||
"bolt11": bolt11,
|
"bolt11": bolt11,
|
||||||
"payment_hash": payment_hash,
|
"payment_hash": payment_hash,
|
||||||
@@ -290,52 +336,10 @@ impl ApiHandler {
|
|||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
Ok(invoice_status_response(path, |hash, id| async move {
|
||||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
self.rpc_handler.settle_content_invoice(&hash, &id).await
|
||||||
Some((id, hash)) => (id, hash),
|
})
|
||||||
None => {
|
.await)
|
||||||
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()),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||||
@@ -389,7 +393,13 @@ impl ApiHandler {
|
|||||||
|
|
||||||
match self.rpc_handler.new_onchain_address().await {
|
match self.rpc_handler.new_onchain_address().await {
|
||||||
Ok(address) if !address.is_empty() => {
|
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!({
|
let body = serde_json::json!({
|
||||||
"address": address,
|
"address": address,
|
||||||
"amount_sats": price_sats,
|
"amount_sats": price_sats,
|
||||||
@@ -439,7 +449,7 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The address must be one we issued for exactly this content item.
|
// 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,
|
Some((cid, price)) if cid == content_id => price,
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
@@ -450,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 !paid {
|
||||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
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;
|
paid = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ impl RpcHandler {
|
|||||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||||
/// response instead of intermittent failures.
|
/// 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,
|
&self,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
method: &str,
|
method: &str,
|
||||||
|
|||||||
@@ -73,6 +73,45 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||||
async fn file_purchase_in_files(
|
async fn file_purchase_in_files(
|
||||||
data_dir: &std::path::Path,
|
data_dir: &std::path::Path,
|
||||||
@@ -506,36 +545,15 @@ impl RpcHandler {
|
|||||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||||
// catches duplicate ids pointing at the same file on the same
|
// catches duplicate ids pointing at the same file on the same
|
||||||
// seller. The owned copy is served from the local cache instead.
|
// 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());
|
return Ok(cached);
|
||||||
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
|
|
||||||
{
|
|
||||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
|
||||||
result["already_owned"] = serde_json::json!(true);
|
|
||||||
result["filename"] = serde_json::json!(o.filename);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
// Cache record exists but bytes are gone — fall through and
|
|
||||||
// repurchase rather than stranding the user.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||||
@@ -870,10 +888,29 @@ impl RpcHandler {
|
|||||||
if !is_valid_v3_onion(onion) {
|
if !is_valid_v3_onion(onion) {
|
||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
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"));
|
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 (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
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;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
@@ -912,7 +949,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
return Ok(serde_json::json!({
|
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() {
|
if !response.status().is_success() {
|
||||||
@@ -921,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
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.context("Failed to read response body")?;
|
.context("Paid file transfer interrupted; retry the download without paying again")?;
|
||||||
use base64::Engine;
|
let filename = params
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
.get("filename")
|
||||||
Ok(serde_json::json!({
|
.and_then(|v| v.as_str())
|
||||||
"data": encoded,
|
.unwrap_or(content_id);
|
||||||
"size": bytes.len(),
|
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.
|
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||||
@@ -1405,3 +1471,19 @@ impl RpcHandler {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "content_tests.rs"]
|
#[path = "content_tests.rs"]
|
||||||
mod tests;
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,3 +71,68 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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.newaddress" => self.handle_lnd_newaddress().await,
|
||||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(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.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::rpc::RpcHandler;
|
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 crate::network::did_dht;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| {
|
.map(|id| {
|
||||||
let is_default = default_id.as_deref() == Some(&id.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 {
|
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||||
(
|
(
|
||||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||||
|
|||||||
@@ -272,28 +272,8 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Fee control: either a confirmation target or an explicit fee rate
|
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||||
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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
peer = pubkey,
|
peer = pubkey,
|
||||||
@@ -473,6 +453,7 @@ impl RpcHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fee_query = close_channel_fee_query(¶ms)?;
|
||||||
let force = params
|
let force = params
|
||||||
.get("force")
|
.get("force")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@@ -498,13 +479,11 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create streaming HTTP client")?;
|
.context("Failed to create streaming HTTP client")?;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
||||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
|
||||||
parts[0], parts[1], force
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut resp = client
|
let mut resp = client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
|
.query(&fee_query)
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
mod channels;
|
mod channels;
|
||||||
|
mod fee_bump;
|
||||||
|
mod fee_policy;
|
||||||
mod info;
|
mod info;
|
||||||
mod macaroons;
|
mod macaroons;
|
||||||
mod payments;
|
mod payments;
|
||||||
|
|||||||
@@ -402,6 +402,9 @@ impl RpcHandler {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Sort by timestamp descending (most recent first)
|
// Sort by timestamp descending (most recent first)
|
||||||
transactions.sort_by(|a, b| {
|
transactions.sort_by(|a, b| {
|
||||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
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"));
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fee control: either a confirmation target or an explicit fee rate
|
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||||
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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
addr = addr,
|
addr = addr,
|
||||||
@@ -238,15 +218,12 @@ impl RpcHandler {
|
|||||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||||
return Err(anyhow::anyhow!("Invalid amount"));
|
return Err(anyhow::anyhow!("Invalid amount"));
|
||||||
}
|
}
|
||||||
let target_conf = params
|
let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?;
|
||||||
.get("target_conf")
|
anyhow::ensure!(
|
||||||
.and_then(|v| v.as_i64())
|
custom_rate.is_none(),
|
||||||
.unwrap_or(6);
|
"Fee estimation requires a confirmation target"
|
||||||
if !(1..=1008).contains(&target_conf) {
|
);
|
||||||
return Err(anyhow::anyhow!(
|
let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
|
||||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
@@ -453,6 +430,56 @@ impl RpcHandler {
|
|||||||
Ok(settled)
|
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).
|
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
@@ -732,10 +759,24 @@ impl RpcHandler {
|
|||||||
total_amount += amount;
|
total_amount += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sat_per_vbyte = params
|
let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
|
||||||
.get("fee_rate_sat_per_vbyte")
|
"sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
|
||||||
.and_then(|v| v.as_u64())
|
}))?;
|
||||||
.unwrap_or(10);
|
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!(
|
info!(
|
||||||
total_amount = total_amount,
|
total_amount = total_amount,
|
||||||
@@ -743,8 +784,6 @@ impl RpcHandler {
|
|||||||
"Creating PSBT for hardware wallet signing"
|
"Creating PSBT for hardware wallet signing"
|
||||||
);
|
);
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let fund_body = serde_json::json!({
|
let fund_body = serde_json::json!({
|
||||||
"raw": {
|
"raw": {
|
||||||
"outputs": lnd_outputs,
|
"outputs": lnd_outputs,
|
||||||
@@ -1444,3 +1483,81 @@ mod tests {
|
|||||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
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>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
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?;
|
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 service_arc = Arc::clone(&self.mesh_service);
|
||||||
let config_for_apply = config.clone();
|
let config_for_apply = config.clone();
|
||||||
|
let flash_jobs = Arc::clone(&self.flash_job);
|
||||||
tokio::spawn(async move {
|
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;
|
let mut service = service_arc.write().await;
|
||||||
if let Some(svc) = service.as_mut() {
|
if let Some(svc) = service.as_mut() {
|
||||||
if let Err(e) = svc.configure(config_for_apply).await {
|
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)
|
// `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
|
// from opening the identical port at the same time and corrupting
|
||||||
// both operations' handshakes.
|
// 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!(
|
anyhow::ensure!(
|
||||||
job.snapshot().await.done,
|
job.snapshot().await.done,
|
||||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
"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?;
|
let probe = mesh::listener::probe_device(&path).await?;
|
||||||
|
drop(flash_guard);
|
||||||
Ok(serde_json::to_value(probe)?)
|
Ok(serde_json::to_value(probe)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -985,28 +985,26 @@ pub(super) async fn get_app_config(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"nginx-proxy-manager" => {
|
"nginx-proxy-manager" => {
|
||||||
|
let storage = crate::container::npm::resolve_storage().await?;
|
||||||
let admin_port = allocator
|
let admin_port = allocator
|
||||||
.allocate_or_get(app_id, 8081, 81)
|
.allocate_or_get(app_id, 8081, 81)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(8081);
|
.unwrap_or(8081);
|
||||||
let http_port = allocator
|
let http_port = allocator
|
||||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
.allocate_or_get("nginx-proxy-manager-http", 8088, 80)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(8084);
|
.unwrap_or(8088);
|
||||||
let https_port = allocator
|
let https_port = allocator
|
||||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(8444);
|
.unwrap_or(8444);
|
||||||
(
|
(
|
||||||
vec![
|
vec![
|
||||||
format!("{}:81", admin_port),
|
format!("127.0.0.1:{}:81", admin_port),
|
||||||
format!("{}:80", http_port),
|
format!("127.0.0.1:{}:80", http_port),
|
||||||
format!("{}:443", https_port),
|
format!("127.0.0.1:{}: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(),
|
|
||||||
],
|
],
|
||||||
|
storage.bind_mounts(),
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -693,7 +693,11 @@ impl RpcHandler {
|
|||||||
// These standalone web UIs have repeatedly lost host listeners
|
// These standalone web UIs have repeatedly lost host listeners
|
||||||
// under Podman's rootless pasta backend while staying healthy internally.
|
// under Podman's rootless pasta backend while staying healthy internally.
|
||||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
// 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) {
|
} else if needs_archy_net(package_id) {
|
||||||
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
||||||
match tokio::process::Command::new("podman")
|
match tokio::process::Command::new("podman")
|
||||||
@@ -1568,88 +1572,8 @@ autopilot.active=false\n",
|
|||||||
super::pine_ha::restart_home_assistant_if_running().await;
|
super::pine_ha::restart_home_assistant_if_running().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if package_id == "filebrowser" {
|
// File Browser credentials are provisioned and verified before the
|
||||||
// Generate a random password (32 bytes, hex-encoded)
|
// server starts. Never attempt a default-password change after launch.
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
||||||
{
|
{
|
||||||
@@ -1912,10 +1836,10 @@ autopilot.active=false\n",
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result<serde_json::Value> {
|
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 credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new(
|
||||||
let password = tokio::fs::read_to_string(secret_path)
|
"/var/lib/archipelago/secrets/filebrowser",
|
||||||
.await
|
))
|
||||||
.unwrap_or_else(|_| "admin".to_string());
|
.await?;
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
@@ -1924,7 +1848,7 @@ autopilot.active=false\n",
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post("http://127.0.0.1:8083/api/login")
|
.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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to FileBrowser")?;
|
.context("Failed to connect to FileBrowser")?;
|
||||||
@@ -1954,17 +1878,16 @@ autopilot.active=false\n",
|
|||||||
super::validation::validate_app_id(app_id)?;
|
super::validation::validate_app_id(app_id)?;
|
||||||
|
|
||||||
if app_id == "filebrowser" {
|
if app_id == "filebrowser" {
|
||||||
let password =
|
let credentials = crate::container::filebrowser::cloud_credentials(
|
||||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
|
std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"),
|
||||||
.await
|
)
|
||||||
.map(|p| p.trim().to_string())
|
.await?;
|
||||||
.unwrap_or_else(|_| "admin".to_string());
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"title": "File Browser credentials",
|
"title": "File Browser credentials",
|
||||||
"description": "Use these credentials when File Browser asks you to sign in.",
|
"description": "Use these credentials when File Browser asks you to sign in.",
|
||||||
"credentials": [
|
"credentials": [
|
||||||
{ "label": "Username", "value": "admin" },
|
{ "label": "Username", "value": credentials.username },
|
||||||
{ "label": "Password", "value": password, "sensitive": true }
|
{ "label": "Password", "value": credentials.password, "sensitive": true }
|
||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1576,41 +1576,110 @@ async fn repair_netbird_network() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn repair_nginx_proxy_manager_container() {
|
async fn repair_nginx_proxy_manager_container() {
|
||||||
repair_nginx_proxy_manager_dirs().await;
|
// 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;
|
||||||
|
}
|
||||||
|
// 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 {
|
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Err(error) = repair_legacy_nginx_proxy_manager().await {
|
||||||
install_log(
|
tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved");
|
||||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
|
||||||
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
|
||||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
|
||||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn repair_nginx_proxy_manager_dirs() {
|
const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous";
|
||||||
let _ = tokio::process::Command::new("sudo")
|
|
||||||
.args([
|
async fn restore_failed_npm_repair() -> Result<()> {
|
||||||
"mkdir",
|
let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?;
|
||||||
"-p",
|
anyhow::ensure!(
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
removed.status.success(),
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
"cannot remove failed NPM replacement; previous container retained"
|
||||||
])
|
);
|
||||||
.output()
|
let renamed =
|
||||||
.await;
|
podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?;
|
||||||
let _ = tokio::process::Command::new("sudo")
|
anyhow::ensure!(
|
||||||
.args([
|
renamed.status.success(),
|
||||||
"chown",
|
"cannot restore previous NPM container name"
|
||||||
"-R",
|
);
|
||||||
"1000:1000",
|
let started = podman_control(&["start", "nginx-proxy-manager"]).await?;
|
||||||
"/var/lib/archipelago/nginx-proxy-manager",
|
anyhow::ensure!(
|
||||||
])
|
started.status.success(),
|
||||||
.output()
|
"previous NPM container restored but failed to start"
|
||||||
.await;
|
);
|
||||||
|
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 {
|
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||||
@@ -1645,36 +1714,75 @@ async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
|||||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
fn npm_repair_environment(values: &[String]) -> Result<Vec<(String, String)>> {
|
||||||
tokio::process::Command::new("sudo")
|
values.iter().map(|value| {
|
||||||
.args([
|
let (key, value) = value.split_once('=').context("invalid NPM environment entry")?;
|
||||||
"mkdir",
|
anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||||
"-p",
|
"unsupported NPM environment name; original container preserved");
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
anyhow::ensure!(!value.contains(['\n', '\r', '\0']),
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
"NPM environment requires explicit migration of a multiline value; original container preserved");
|
||||||
])
|
Ok((key.to_owned(), value.to_owned()))
|
||||||
.output()
|
}).collect()
|
||||||
.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;
|
|
||||||
|
|
||||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
struct NpmRepairEnvironmentFile(std::path::PathBuf);
|
||||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
|
||||||
|
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![
|
let mut args = vec![
|
||||||
"run".to_string(),
|
"run".to_string(),
|
||||||
"-d".to_string(),
|
"-d".to_string(),
|
||||||
"--name".to_string(),
|
"--name".to_string(),
|
||||||
"nginx-proxy-manager".to_string(),
|
"nginx-proxy-manager".to_string(),
|
||||||
"--restart=unless-stopped".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(),
|
"--cap-drop=ALL".to_string(),
|
||||||
"--security-opt=no-new-privileges:true".to_string(),
|
"--security-opt=no-new-privileges:true".to_string(),
|
||||||
"--pids-limit=4096".to_string(),
|
"--pids-limit=4096".to_string(),
|
||||||
@@ -1682,24 +1790,32 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
|||||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||||
args.extend([
|
args.extend([
|
||||||
"-p".to_string(),
|
"-p".to_string(),
|
||||||
"8081:81".to_string(),
|
"127.0.0.1:8081:81".to_string(),
|
||||||
"-p".to_string(),
|
"-p".to_string(),
|
||||||
"8084:80".to_string(),
|
"127.0.0.1:8088:80".to_string(),
|
||||||
"-p".to_string(),
|
"-p".to_string(),
|
||||||
"8444:443".to_string(),
|
"127.0.0.1:8444:443".to_string(),
|
||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
format!("{}:/data", storage.data),
|
||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
format!("{}:/etc/letsencrypt", storage.certificates),
|
||||||
"--memory".to_string(),
|
"--memory".to_string(),
|
||||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||||
"--cpus=2".to_string(),
|
"--cpus=2".to_string(),
|
||||||
]);
|
]);
|
||||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
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);
|
args.push(image);
|
||||||
|
|
||||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
let mut command = tokio::process::Command::new("podman");
|
||||||
let output = podman_control(&refs).await?;
|
command.args(&args);
|
||||||
|
let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"podman run nginx-proxy-manager failed: {}",
|
"podman run nginx-proxy-manager failed: {}",
|
||||||
@@ -1767,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec<u16> {
|
|||||||
"vaultwarden" => vec![8082],
|
"vaultwarden" => vec![8082],
|
||||||
"gitea" => vec![3001, 2222, 3000],
|
"gitea" => vec![3001, 2222, 3000],
|
||||||
"nextcloud" => vec![8085],
|
"nextcloud" => vec![8085],
|
||||||
"nginx-proxy-manager" => vec![8081, 8084, 8444],
|
"nginx-proxy-manager" => vec![8081, 8088, 8444],
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
ports
|
ports
|
||||||
@@ -1778,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16
|
|||||||
ports.push(3000);
|
ports.push(3000);
|
||||||
}
|
}
|
||||||
if container_name == "nginx-proxy-manager" {
|
if container_name == "nginx-proxy-manager" {
|
||||||
for port in [8084, 8444] {
|
for port in [8088, 8444] {
|
||||||
if !ports.contains(&port) {
|
if !ports.contains(&port) {
|
||||||
ports.push(port);
|
ports.push(port);
|
||||||
}
|
}
|
||||||
@@ -1843,6 +1959,7 @@ async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_sec
|
|||||||
loop {
|
loop {
|
||||||
let ready = match container_name {
|
let ready = match container_name {
|
||||||
"uptime-kuma" => http_host_port_ready(port, "/").await,
|
"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))
|
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||||
.await
|
.await
|
||||||
.is_ok(),
|
.is_ok(),
|
||||||
@@ -2151,6 +2268,38 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -142,11 +142,40 @@ const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\
|
|||||||
/// catalog refresh/reconciliation. Replacing the app tree in the background
|
/// catalog refresh/reconciliation. Replacing the app tree in the background
|
||||||
/// could let a reload observe its temporary empty state and forget disk-only apps.
|
/// could let a reload observe its temporary empty state and forget disk-only apps.
|
||||||
pub async fn ensure_runtime_assets_ready() {
|
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 {
|
match run_runtime_assets().await {
|
||||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
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
|
// Repair the narrowly recognized legacy NPM tunnel override before app
|
||||||
// reconciliation. The embedded script ships in both OTA and ISO binaries.
|
// reconciliation. The embedded script ships in both OTA and ISO binaries.
|
||||||
// It preserves native wallet services and refuses unknown custom routing.
|
// It preserves native wallet services and refuses unknown custom routing.
|
||||||
@@ -176,6 +205,52 @@ pub async fn ensure_runtime_assets_ready() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 —
|
/// 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.
|
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||||
pub async fn ensure_doctor_installed() {
|
pub async fn ensure_doctor_installed() {
|
||||||
@@ -421,17 +496,28 @@ async fn run_runtime_assets() -> Result<bool> {
|
|||||||
if nginx_src.exists() {
|
if nginx_src.exists() {
|
||||||
let src_s = nginx_src.to_string_lossy().to_string();
|
let src_s = nginx_src.to_string_lossy().to_string();
|
||||||
let status = host_sudo(&[
|
let status = host_sudo(&[
|
||||||
"install",
|
"python3",
|
||||||
"-m",
|
"-c",
|
||||||
"644",
|
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||||
|
"--install",
|
||||||
&src_s,
|
&src_s,
|
||||||
"/etc/nginx/sites-available/archipelago",
|
|
||||||
])
|
])
|
||||||
.await
|
.await
|
||||||
.context("install nginx-archipelago.conf")?;
|
.context("install guarded nginx-archipelago.conf")?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +534,8 @@ async fn run_runtime_assets() -> Result<bool> {
|
|||||||
"archipelago-doctor.service",
|
"archipelago-doctor.service",
|
||||||
"archipelago-doctor.timer",
|
"archipelago-doctor.timer",
|
||||||
"archipelago-host-secrets-audit.service",
|
"archipelago-host-secrets-audit.service",
|
||||||
|
"archipelago-npm-bridge.service",
|
||||||
|
"archipelago-npm-bridge.timer",
|
||||||
] {
|
] {
|
||||||
let src = configs.join(unit);
|
let src = configs.join(unit);
|
||||||
if src.exists() {
|
if src.exists() {
|
||||||
@@ -475,7 +563,7 @@ async fn run_runtime_assets() -> Result<bool> {
|
|||||||
// or directory"). Skipped when byte-identical; a running daemon is
|
// or directory"). Skipped when byte-identical; a running daemon is
|
||||||
// unaffected (install replaces the inode) and picks the new binary up
|
// unaffected (install replaces the inode) and picks the new binary up
|
||||||
// on its next spawn.
|
// 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);
|
let src = runtime_dir.join("radio-tools").join(tool);
|
||||||
if !src.exists() {
|
if !src.exists() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
|
|||||||
// Never let an unknown future requirement become an unsafe partial match.
|
// Never let an unknown future requirement become an unsafe partial match.
|
||||||
for variant in entry.manifest_variants.into_iter().rev() {
|
for variant in entry.manifest_variants.into_iter().rev() {
|
||||||
if !variant.requires.is_empty()
|
if !variant.requires.is_empty()
|
||||||
&& variant
|
&& variant.requires.iter().all(|capability| {
|
||||||
.requires
|
matches!(
|
||||||
.iter()
|
capability.as_str(),
|
||||||
.all(|capability| capability == "runtime-migration-backup-v1")
|
"runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1"
|
||||||
|
)
|
||||||
|
})
|
||||||
{
|
{
|
||||||
return Some(variant.manifest);
|
return Some(variant.manifest);
|
||||||
}
|
}
|
||||||
@@ -468,6 +470,12 @@ pub struct CatalogRefresh {
|
|||||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> {
|
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)
|
let mirrors = crate::update::load_mirrors(data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -514,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")))
|
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)> {
|
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
|
||||||
let resp = client.get(url).send().await?;
|
let resp = client.get(url).send().await?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
@@ -582,6 +633,77 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||||
let raw = serde_json::json!({
|
let raw = serde_json::json!({
|
||||||
@@ -608,6 +730,25 @@ mod tests {
|
|||||||
assert!(chosen["app"]["container"].get("network").is_none());
|
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]
|
#[test]
|
||||||
fn parses_and_ignores_unknown_fields() {
|
fn parses_and_ignores_unknown_fields() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ use crate::data_model::{
|
|||||||
/// stopped-app restoration path in agreement with live-container discovery.
|
/// stopped-app restoration path in agreement with live-container discovery.
|
||||||
fn canonical_package_id(name: &str) -> &str {
|
fn canonical_package_id(name: &str) -> &str {
|
||||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||||
"immich_server" => "immich",
|
"immich_server" | "immich-server" => "immich",
|
||||||
|
"immich-postgres" => "immich_postgres",
|
||||||
|
"immich-redis" => "immich_redis",
|
||||||
"mempool-web" | "mempool-frontend" => "mempool",
|
"mempool-web" | "mempool-frontend" => "mempool",
|
||||||
|
"btcpay" | "btcpayserver" => "btcpay-server",
|
||||||
name => name,
|
name => name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +446,28 @@ mod lifecycle_regression_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
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]
|
#[test]
|
||||||
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||||
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||||
|
|||||||
@@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi
|
|||||||
const DEFAULT_CONFIG_JSON: &str =
|
const DEFAULT_CONFIG_JSON: &str =
|
||||||
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
|
"{\"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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EnsurePaths {
|
pub struct EnsurePaths {
|
||||||
pub srv_root: PathBuf,
|
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<()> {
|
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||||
let tmp = paths.config_path.with_extension("tmp");
|
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(()) => {
|
Ok(()) => {
|
||||||
fs::rename(&tmp, &paths.config_path)
|
fs::rename(&tmp, &paths.config_path)
|
||||||
.await
|
.await
|
||||||
@@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
|||||||
let script = format!(
|
let script = format!(
|
||||||
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
|
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
|
||||||
shell_quote(&paths.config_path.to_string_lossy()),
|
shell_quote(&paths.config_path.to_string_lossy()),
|
||||||
DEFAULT_CONFIG_JSON
|
config
|
||||||
);
|
);
|
||||||
let status = host_sudo(&["sh", "-lc", &script])
|
let status = host_sudo(&["sh", "-lc", &script])
|
||||||
.await
|
.await
|
||||||
@@ -312,6 +401,62 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[tokio::test]
|
||||||
async fn ensure_config_creates_dirs_and_file() {
|
async fn ensure_config_creates_dirs_and_file() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod image_policy;
|
|||||||
pub mod image_versions;
|
pub mod image_versions;
|
||||||
pub mod lnd;
|
pub mod lnd;
|
||||||
pub mod migration_backup;
|
pub mod migration_backup;
|
||||||
|
pub mod npm;
|
||||||
pub mod prod_orchestrator;
|
pub mod prod_orchestrator;
|
||||||
pub mod quadlet;
|
pub mod quadlet;
|
||||||
pub mod registry;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,16 +92,71 @@ fn is_restart_sensitive_app(app_id: &str) -> bool {
|
|||||||
fn is_builtin_network_mode(network: &str) -> bool {
|
fn is_builtin_network_mode(network: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
network,
|
network,
|
||||||
"host" | "bridge" | "none" | "slirp4netns" | "pasta"
|
"host"
|
||||||
|
| "bridge"
|
||||||
|
| "none"
|
||||||
|
| "slirp4netns"
|
||||||
|
| "slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||||
|
| "pasta"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only an explicitly selected rootless mode establishes drift. An omitted
|
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||||
// network delegates to Podman and must not recreate unrelated installed apps.
|
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||||
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||||
matches!(expected, Some("slirp4netns" | "pasta"))
|
let actual = actual.trim();
|
||||||
&& !actual.trim().is_empty()
|
if actual.is_empty() {
|
||||||
&& actual.trim().split(':').next() != expected
|
return false;
|
||||||
|
}
|
||||||
|
match expected {
|
||||||
|
Some(
|
||||||
|
expected @ ("slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"),
|
||||||
|
) => {
|
||||||
|
let (mode, options) = actual.split_once(':').unwrap_or((actual, ""));
|
||||||
|
let required = expected.split_once(':').unwrap().1;
|
||||||
|
mode != "slirp4netns"
|
||||||
|
|| required.split(',').any(|wanted| {
|
||||||
|
let key = wanted.split_once('=').unwrap().0;
|
||||||
|
!options.split(',').any(|option| option == wanted)
|
||||||
|
|| options.split(',').any(|option| {
|
||||||
|
option.split_once('=').is_some_and(|(name, _)| name == key)
|
||||||
|
&& option != wanted
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(mode @ ("slirp4netns" | "pasta")) => actual.split(':').next() != Some(mode),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-created containers may omit rootless options from HostConfig.NetworkMode.
|
||||||
|
// generate spec retains them; inspect alone would cause an endless repair loop.
|
||||||
|
fn rootless_network_from_spec(bytes: &[u8]) -> Option<String> {
|
||||||
|
let spec: serde_json::Value = serde_json::from_slice(bytes).ok()?;
|
||||||
|
let mode = spec.get("netns")?.get("nsmode")?.as_str()?;
|
||||||
|
if !matches!(mode, "slirp4netns" | "pasta") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let options = spec
|
||||||
|
.get("network_options")
|
||||||
|
.and_then(|options| options.get(mode));
|
||||||
|
match options {
|
||||||
|
None => Some(mode.to_string()),
|
||||||
|
Some(options) => {
|
||||||
|
let options = options
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.as_str())
|
||||||
|
.collect::<Option<Vec<_>>>()?;
|
||||||
|
if options.is_empty() {
|
||||||
|
Some(mode.to_string())
|
||||||
|
} else {
|
||||||
|
Some(format!("{mode}:{}", options.join(",")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
|
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
|
||||||
@@ -175,6 +230,11 @@ fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 {
|
fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 {
|
||||||
|
// First NPM initialization generates keys and migrates its database before
|
||||||
|
// exposing nginx. Its readiness budget must not depend on the network driver.
|
||||||
|
if manifest.app.id == "nginx-proxy-manager" {
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
if manifest.app.id == "uptime-kuma" {
|
if manifest.app.id == "uptime-kuma" {
|
||||||
return 420;
|
return 420;
|
||||||
}
|
}
|
||||||
@@ -2425,6 +2485,49 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
match status.state {
|
match status.state {
|
||||||
ContainerState::Running => {
|
ContainerState::Running => {
|
||||||
|
// Legacy runtime path: migrate credentials once without
|
||||||
|
// recreating accounts or changing operator passwords.
|
||||||
|
// Quadlet installations receive the same helper through
|
||||||
|
// the required ExecStartPre drift/restart above.
|
||||||
|
if app_id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||||
|
let secrets = self.secrets_dir.join("filebrowser");
|
||||||
|
let managed = filebrowser::cloud_credentials(&secrets)
|
||||||
|
.await
|
||||||
|
.map(|value| value.schema == 1)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !managed {
|
||||||
|
if !self.should_attempt_repair(&name).await {
|
||||||
|
return Ok(ReconcileAction::Left(
|
||||||
|
"filebrowser-credential-repair-budget-exhausted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let unit_managed = self.runtime.cli_name() == "podman"
|
||||||
|
&& quadlet::unit_exists(&name).await;
|
||||||
|
let service = format!("{name}.service");
|
||||||
|
if unit_managed {
|
||||||
|
quadlet::stop_service(&service).await?;
|
||||||
|
} else {
|
||||||
|
self.runtime.stop_container(&name).await?;
|
||||||
|
}
|
||||||
|
let prepared = filebrowser::prepare_credentials(
|
||||||
|
&self.filebrowser_paths,
|
||||||
|
&secrets,
|
||||||
|
&status.image,
|
||||||
|
self.runtime.cli_name(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Restore service availability even when setup
|
||||||
|
// rolled back. Preserve the setup failure itself.
|
||||||
|
let started = if unit_managed {
|
||||||
|
quadlet::restart_service(&service).await
|
||||||
|
} else {
|
||||||
|
self.runtime.start_container(&name).await
|
||||||
|
};
|
||||||
|
prepared?;
|
||||||
|
started?;
|
||||||
|
return Ok(ReconcileAction::Started);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Zombie guard: podman can report a container "running"
|
// Zombie guard: podman can report a container "running"
|
||||||
// after its process has died (conmon SIGKILLed in a
|
// after its process has died (conmon SIGKILLed in a
|
||||||
// cgroup cascade on archipelago restart, etc.). Such a
|
// cgroup cascade on archipelago restart, etc.). Such a
|
||||||
@@ -2971,6 +3074,18 @@ impl ProdContainerOrchestrator {
|
|||||||
self.ensure_manifest_files(manifest).await?;
|
self.ensure_manifest_files(manifest).await?;
|
||||||
self.apply_data_uid(manifest).await?;
|
self.apply_data_uid(manifest).await?;
|
||||||
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
||||||
|
if manifest.app.id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||||
|
let image = manifest.app.container.image.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("File Browser needs a pinned image for credential setup")
|
||||||
|
})?;
|
||||||
|
filebrowser::prepare_credentials(
|
||||||
|
&self.filebrowser_paths,
|
||||||
|
&self.secrets_dir.join("filebrowser"),
|
||||||
|
image,
|
||||||
|
self.runtime.cli_name(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3068,6 +3183,11 @@ impl ProdContainerOrchestrator {
|
|||||||
container = %name,
|
container = %name,
|
||||||
"Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit"
|
"Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit"
|
||||||
);
|
);
|
||||||
|
// Resolve active persistent mounts before the old inspect record is
|
||||||
|
// removed. NPM may use a legacy or operator-selected data directory.
|
||||||
|
let mut resolved = lm.manifest.clone();
|
||||||
|
self.resolve_dynamic_env(&mut resolved).await?;
|
||||||
|
self.backup_runtime_change(name, &resolved).await?;
|
||||||
// Stop+remove the old container record. Volumes survive (host
|
// Stop+remove the old container record. Volumes survive (host
|
||||||
// bind mounts are not touched by podman rm).
|
// bind mounts are not touched by podman rm).
|
||||||
self.runtime
|
self.runtime
|
||||||
@@ -3077,8 +3197,6 @@ impl ProdContainerOrchestrator {
|
|||||||
|
|
||||||
// Re-render the manifest with dynamic env baked in, then go
|
// Re-render the manifest with dynamic env baked in, then go
|
||||||
// through the same install path a fresh install would.
|
// through the same install path a fresh install would.
|
||||||
let mut resolved = lm.manifest.clone();
|
|
||||||
self.resolve_dynamic_env(&mut resolved).await?;
|
|
||||||
self.install_via_quadlet(&resolved, name)
|
self.install_via_quadlet(&resolved, name)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?;
|
.with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?;
|
||||||
@@ -3153,7 +3271,12 @@ impl ProdContainerOrchestrator {
|
|||||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||||
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
||||||
|
let restart_for_managed_override =
|
||||||
|
quadlet::redundant_managed_network_override(&unit, &unit_dir)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
let needs_restart = restart_required
|
let needs_restart = restart_required
|
||||||
|
|| restart_for_managed_override
|
||||||
|| restart_for_port_change
|
|| restart_for_port_change
|
||||||
|| restart_for_network_alias_change
|
|| restart_for_network_alias_change
|
||||||
|| restart_for_exec_change
|
|| restart_for_exec_change
|
||||||
@@ -3529,6 +3652,7 @@ impl ProdContainerOrchestrator {
|
|||||||
host_mdns: "test.local".to_string(),
|
host_mdns: "test.local".to_string(),
|
||||||
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
@@ -3546,10 +3670,53 @@ impl ProdContainerOrchestrator {
|
|||||||
// demand (it costs a podman call) only for manifests that use
|
// demand (it costs a podman call) only for manifests that use
|
||||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
// Likewise filled on demand, only for manifests that use
|
||||||
|
// {{NODE_IDENTITY_PUBKEYS}}.
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nostr public keys of the identities the app identity picker offers, for
|
||||||
|
/// the `{{NODE_IDENTITY_PUBKEYS}}` derived-env placeholder. The node
|
||||||
|
/// identity is recognised the way `identity.list` marks `is_node`: by the
|
||||||
|
/// node's ed25519 public key, read here from `identity/node_key.pub`
|
||||||
|
/// (the file `server_info.pubkey` is derived from at startup). The record
|
||||||
|
/// mirrored from the node key has a `node-` id, so the picker's prefix rule
|
||||||
|
/// hides it either way. The key is only read, never created: a missing or
|
||||||
|
/// malformed file is an error. An empty set is an error too, so an app is
|
||||||
|
/// never handed an empty owner list.
|
||||||
|
async fn node_identity_pubkeys(&self) -> Result<String> {
|
||||||
|
let node_pubkey_hex = self.node_pubkey_hex().await?;
|
||||||
|
let pubkeys = crate::identity_manager::IdentityManager::new(&self.data_dir)
|
||||||
|
.await?
|
||||||
|
.app_signable_nostr_pubkeys(&node_pubkey_hex)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
!pubkeys.is_empty(),
|
||||||
|
"no user identity with a Nostr key is available for apps to sign with; \
|
||||||
|
create one under Web5 \u{2192} Identities"
|
||||||
|
);
|
||||||
|
Ok(pubkeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node's ed25519 public key as lowercase hex, read from
|
||||||
|
/// `identity/node_key.pub` (raw 32 bytes, as `NodeIdentity` writes it)
|
||||||
|
/// without the logging or key creation of `NodeIdentity::load_or_create`.
|
||||||
|
async fn node_pubkey_hex(&self) -> Result<String> {
|
||||||
|
let path = self.data_dir.join("identity").join("node_key.pub");
|
||||||
|
let bytes = tokio::fs::read(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading the node public key {}", path.display()))?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes.len() == 32,
|
||||||
|
"node public key {} is {} bytes, expected 32",
|
||||||
|
path.display(),
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
Ok(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||||
/// Defaults to `bitcoin-knots` when none is running (B12).
|
/// Defaults to `bitcoin-knots` when none is running (B12).
|
||||||
@@ -3748,6 +3915,11 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||||
|
if manifest.app.id == "nginx-proxy-manager" {
|
||||||
|
crate::container::npm::resolve_storage()
|
||||||
|
.await?
|
||||||
|
.apply(manifest)?;
|
||||||
|
}
|
||||||
// Idempotency guard: partitioning already ran on this instance.
|
// Idempotency guard: partitioning already ran on this instance.
|
||||||
// Re-running would re-taint against an environment that no longer
|
// Re-running would re-taint against an environment that no longer
|
||||||
// contains the composite entries and silently drop them. Callers
|
// contains the composite entries and silently drop them. Callers
|
||||||
@@ -3817,6 +3989,22 @@ impl ProdContainerOrchestrator {
|
|||||||
{
|
{
|
||||||
facts.bitcoin_host = self.bitcoin_host().await;
|
facts.bitcoin_host = self.bitcoin_host().await;
|
||||||
}
|
}
|
||||||
|
// The identities' keys are read only for manifests that template them.
|
||||||
|
if manifest
|
||||||
|
.app
|
||||||
|
.container
|
||||||
|
.derived_env
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.template.contains("{{NODE_IDENTITY_PUBKEYS}}"))
|
||||||
|
{
|
||||||
|
facts.node_identity_pubkeys =
|
||||||
|
self.node_identity_pubkeys().await.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"resolving {{{{NODE_IDENTITY_PUBKEYS}}}} for {}",
|
||||||
|
manifest.app.id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
let mut env = manifest.app.environment.clone();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||||
@@ -4003,7 +4191,12 @@ impl ProdContainerOrchestrator {
|
|||||||
if unmanaged
|
if unmanaged
|
||||||
&& matches!(
|
&& matches!(
|
||||||
manifest.app.container.network.as_deref(),
|
manifest.app.container.network.as_deref(),
|
||||||
Some("slirp4netns" | "pasta")
|
Some(
|
||||||
|
"slirp4netns"
|
||||||
|
| "slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||||
|
| "pasta"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if let Ok(output) = tokio::process::Command::new("podman")
|
if let Ok(output) = tokio::process::Command::new("podman")
|
||||||
@@ -4011,13 +4204,36 @@ impl ProdContainerOrchestrator {
|
|||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if output.status.success()
|
if output.status.success() {
|
||||||
&& rootless_network_mode_drifted(
|
let mut actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if matches!(
|
||||||
manifest.app.container.network.as_deref(),
|
manifest.app.container.network.as_deref(),
|
||||||
&String::from_utf8_lossy(&output.stdout),
|
Some(
|
||||||
)
|
"slirp4netns:allow_host_loopback=true"
|
||||||
{
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||||
return true;
|
)
|
||||||
|
) && actual == "slirp4netns"
|
||||||
|
{
|
||||||
|
let spec = tokio::process::Command::new("podman")
|
||||||
|
.args(["generate", "spec", name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
actual = match spec {
|
||||||
|
Ok(spec) if spec.status.success() => {
|
||||||
|
rootless_network_from_spec(&spec.stdout).unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
if actual.is_empty() {
|
||||||
|
tracing::warn!(app = %name, "Could not verify rootless network options; retaining the existing container");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rootless_network_mode_drifted(
|
||||||
|
manifest.app.container.network.as_deref(),
|
||||||
|
&actual,
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4881,6 +5097,26 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
||||||
/// separate operation owned by the data layer, not this orchestrator).
|
/// separate operation owned by the data layer, not this orchestrator).
|
||||||
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
||||||
|
// A removed catalog entry must remain uninstallable. The RPC caller
|
||||||
|
// still removes legacy containers and persists uninstall intent after
|
||||||
|
// confirming they are gone; do not block it on a missing manifest.
|
||||||
|
if !self.state.read().await.manifests.contains_key(app_id) {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!app_id.is_empty()
|
||||||
|
&& app_id.len() <= 128
|
||||||
|
&& app_id
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
|
||||||
|
"Invalid app id"
|
||||||
|
);
|
||||||
|
let lock = self.app_lock(app_id).await;
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
||||||
|
self.remove_quadlet_unit_if_present(&name).await?;
|
||||||
|
}
|
||||||
|
self.state.write().await.disabled.insert(app_id.to_string());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let lm = self.loaded(app_id).await?;
|
let lm = self.loaded(app_id).await?;
|
||||||
let lock = self.app_lock(app_id).await;
|
let lock = self.app_lock(app_id).await;
|
||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
@@ -5157,8 +5393,68 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_legacy_gateway_converges_and_rejects_conflicting_network_options() {
|
||||||
|
let expected = Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24");
|
||||||
|
assert!(is_builtin_network_mode(expected.unwrap()));
|
||||||
|
for actual in [
|
||||||
|
"pasta",
|
||||||
|
"slirp4netns:allow_host_loopback=true",
|
||||||
|
"slirp4netns:allow_host_loopback=true,cidr=10.0.2.0/24",
|
||||||
|
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24,cidr=10.0.2.0/24",
|
||||||
|
] {
|
||||||
|
assert!(rootless_network_mode_drifted(expected, actual), "{actual}");
|
||||||
|
}
|
||||||
|
let actual = "slirp4netns:cidr=169.254.1.0/24,allow_host_loopback=true,mtu=65520";
|
||||||
|
assert!(!rootless_network_mode_drifted(expected, actual));
|
||||||
|
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["cidr=169.254.1.0/24","allow_host_loopback=true"]}}"#;
|
||||||
|
assert!(!rootless_network_mode_drifted(
|
||||||
|
expected,
|
||||||
|
&rootless_network_from_spec(spec).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_initialization_budget_survives_network_migration() {
|
||||||
|
let mut manifest = AppManifest::parse(include_str!(
|
||||||
|
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
for network in ["pasta", "slirp4netns:allow_host_loopback=true"] {
|
||||||
|
manifest.app.container.network = Some(network.to_string());
|
||||||
|
assert_eq!(host_port_wait_timeout_secs(&manifest), 180);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_created_rootless_options_do_not_cause_repeated_recreation() {
|
||||||
|
let expected = Some("slirp4netns:allow_host_loopback=true");
|
||||||
|
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["allow_host_loopback=true"]}}"#;
|
||||||
|
let actual = rootless_network_from_spec(spec).unwrap();
|
||||||
|
assert!(!rootless_network_mode_drifted(expected, &actual));
|
||||||
|
let plain = rootless_network_from_spec(br#"{"netns":{"nsmode":"slirp4netns"}}"#).unwrap();
|
||||||
|
assert!(rootless_network_mode_drifted(expected, &plain));
|
||||||
|
assert!(rootless_network_from_spec(b"invalid").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||||
|
let npm = Some("slirp4netns:allow_host_loopback=true");
|
||||||
|
assert!(is_builtin_network_mode(npm.unwrap()));
|
||||||
|
for actual in [
|
||||||
|
"pasta",
|
||||||
|
"bridge",
|
||||||
|
"slirp4netns",
|
||||||
|
"slirp4netns:allow_host_loopback=false",
|
||||||
|
] {
|
||||||
|
assert!(rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||||
|
}
|
||||||
|
for actual in [
|
||||||
|
"slirp4netns:allow_host_loopback=true",
|
||||||
|
"slirp4netns:mtu=65520,allow_host_loopback=true",
|
||||||
|
] {
|
||||||
|
assert!(!rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||||
|
}
|
||||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||||
assert!(!rootless_network_mode_drifted(
|
assert!(!rootless_network_mode_drifted(
|
||||||
@@ -6126,6 +6422,143 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NODE_IDENTITY_PUBKEYS_YAML: &str = "app:\n id: wildbloom-node\n name: wildbloom-node\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: WILDBLOOM_ALLOW_PUBKEYS\n template: \"{{NODE_IDENTITY_PUBKEYS}}\"\n";
|
||||||
|
|
||||||
|
/// Writes `pubkey_hex` as the node public key, in `NodeIdentity`'s format.
|
||||||
|
async fn write_node_pubkey(orch: &ProdContainerOrchestrator, pubkey_hex: &str) {
|
||||||
|
let dir = orch.data_dir().join("identity");
|
||||||
|
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||||
|
tokio::fs::write(dir.join("node_key.pub"), hex::decode(pubkey_hex).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
|
||||||
|
// The owners must be exactly the identities the app signer offers:
|
||||||
|
// the user identities, never the node's own identity.
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let orch = orch_with(rt).await;
|
||||||
|
let mgr = crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for name in ["Personal", "Business"] {
|
||||||
|
let r = mgr
|
||||||
|
.create(
|
||||||
|
name.to_string(),
|
||||||
|
crate::identity_manager::IdentityPurpose::Personal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
expected.push(r.nostr_pubkey.unwrap().to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
expected.sort();
|
||||||
|
// The node key is held by an identity with a uuid id and an ordinary
|
||||||
|
// name, so only the `is_node` match, through the key read from
|
||||||
|
// node_key.pub, can keep it out.
|
||||||
|
let laptop = mgr
|
||||||
|
.create(
|
||||||
|
"Laptop".to_string(),
|
||||||
|
crate::identity_manager::IdentityPurpose::Personal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!laptop.id.starts_with("node-"));
|
||||||
|
let laptop_nostr = laptop.nostr_pubkey.clone().unwrap();
|
||||||
|
write_node_pubkey(&orch, &laptop.pubkey_hex).await;
|
||||||
|
|
||||||
|
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||||
|
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||||
|
|
||||||
|
let env = &manifest.app.environment;
|
||||||
|
let want = format!("WILDBLOOM_ALLOW_PUBKEYS={}", expected.join(","));
|
||||||
|
assert!(env.iter().any(|e| e == &want), "env was {env:?}");
|
||||||
|
assert!(
|
||||||
|
!env.iter().any(|e| e.contains(&laptop_nostr)),
|
||||||
|
"node identity leaked into {env:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_identity_pubkeys_placeholder_refuses_an_empty_set() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let orch = orch_with(rt).await;
|
||||||
|
// A node key with only the node's own identity: nothing is signable.
|
||||||
|
let node = crate::identity::NodeIdentity::load_or_create(&orch.data_dir().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.create_from_signing_key(
|
||||||
|
"Node".to_string(),
|
||||||
|
crate::identity_manager::IdentityPurpose::Personal,
|
||||||
|
node.signing_key().clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||||
|
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("NODE_IDENTITY_PUBKEYS"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains("no user identity"), "unexpected error: {msg}");
|
||||||
|
assert!(
|
||||||
|
!manifest
|
||||||
|
.app
|
||||||
|
.environment
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")),
|
||||||
|
"an empty owner list must never render"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_identity_pubkeys_placeholder_needs_the_node_key_and_never_creates_it() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let orch = orch_with(rt).await;
|
||||||
|
crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.create(
|
||||||
|
"Personal".to_string(),
|
||||||
|
crate::identity_manager::IdentityPurpose::Personal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let identity_dir = orch.data_dir().join("identity");
|
||||||
|
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||||
|
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("node public key"),
|
||||||
|
"unexpected error: {err:#}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!identity_dir.join("node_key").exists(),
|
||||||
|
"a node key was created"
|
||||||
|
);
|
||||||
|
assert!(!identity_dir.join("node_key.pub").exists());
|
||||||
|
|
||||||
|
// A malformed key file is refused, not reinterpreted.
|
||||||
|
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
|
||||||
|
tokio::fs::write(identity_dir.join("node_key.pub"), "ab".repeat(32))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("expected 32"),
|
||||||
|
"unexpected error: {err:#}"
|
||||||
|
);
|
||||||
|
assert!(!manifest
|
||||||
|
.app
|
||||||
|
.environment
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")));
|
||||||
|
}
|
||||||
|
|
||||||
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||||
/// generated secret plus a secret_env that reads it, which is what makes
|
/// generated secret plus a secret_env that reads it, which is what makes
|
||||||
/// the credential participate in secret_env_hash.
|
/// the credential participate in secret_env_hash.
|
||||||
@@ -7435,6 +7868,19 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let orch = orch_with(rt.clone()).await;
|
||||||
|
orch.remove("cryptpad", true).await.unwrap();
|
||||||
|
assert!(orch.state.read().await.disabled.contains("cryptpad"));
|
||||||
|
assert!(
|
||||||
|
rt.calls().is_empty(),
|
||||||
|
"legacy RPC teardown owns the actual containers"
|
||||||
|
);
|
||||||
|
assert!(orch.remove("../other", true).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ pub enum NetworkMode {
|
|||||||
/// Rootless slirp4netns networking. Podman rejects network aliases with
|
/// Rootless slirp4netns networking. Podman rejects network aliases with
|
||||||
/// this mode, so render only Network=slirp4netns.
|
/// this mode, so render only Network=slirp4netns.
|
||||||
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
|
/// Rootless pasta networking. This is more reliable than slirp4netns for
|
||||||
/// host port forwarding on long-running web apps.
|
/// host port forwarding on long-running web apps.
|
||||||
Pasta,
|
Pasta,
|
||||||
@@ -229,6 +233,15 @@ impl QuadletUnit {
|
|||||||
NetworkMode::Host => {
|
NetworkMode::Host => {
|
||||||
let _ = writeln!(s, "Network=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 => {
|
NetworkMode::Slirp4netns => {
|
||||||
let _ = writeln!(s, "Network=slirp4netns");
|
let _ = writeln!(s, "Network=slirp4netns");
|
||||||
}
|
}
|
||||||
@@ -348,6 +361,26 @@ impl QuadletUnit {
|
|||||||
}
|
}
|
||||||
let _ = writeln!(s);
|
let _ = writeln!(s);
|
||||||
let _ = writeln!(s, "[Service]");
|
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
|
// Dependency-gated apps may legitimately keep their container entrypoint
|
||||||
// in a wait loop before the actual daemon binds ports. Fedimint waits
|
// in a wait loop before the actual daemon binds ports. Fedimint waits
|
||||||
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
|
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
|
||||||
@@ -447,6 +480,12 @@ impl QuadletUnit {
|
|||||||
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
||||||
_ => match app.container.network.as_deref() {
|
_ => match app.container.network.as_deref() {
|
||||||
Some("slirp4netns") => NetworkMode::Slirp4netns,
|
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("pasta") => NetworkMode::Pasta,
|
||||||
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
||||||
_ => NetworkMode::Default,
|
_ => NetworkMode::Default,
|
||||||
@@ -713,29 +752,76 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically write `unit` into `dir/<name>.container` if the bytes
|
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||||
/// differ from what's already there. Returns true if the file changed.
|
/// 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> {
|
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||||
let path = dir.join(unit.unit_filename());
|
let path = dir.join(unit.unit_filename());
|
||||||
let new_bytes = unit.render();
|
let new_bytes = unit.render();
|
||||||
|
let redundant = redundant_managed_network_override(unit, dir).await?;
|
||||||
if let Ok(old) = fs::read_to_string(&path).await {
|
let changed = fs::read_to_string(&path)
|
||||||
if old == new_bytes {
|
.await
|
||||||
return Ok(false);
|
.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()))?;
|
||||||
}
|
}
|
||||||
|
if let Some(override_path) = &redundant {
|
||||||
fs::create_dir_all(dir)
|
retire_managed_network_override(override_path).await?;
|
||||||
.await
|
}
|
||||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
Ok(changed || redundant.is_some())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload the user systemd manager. Required after any quadlet write
|
/// Reload the user systemd manager. Required after any quadlet write
|
||||||
@@ -1024,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
|
// Entrypoint= and Exec= together define what the container runs, so a drift
|
||||||
// in either must recreate the container (e.g. when this renderer first
|
// in either must recreate the container (e.g. when this renderer first
|
||||||
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
|
// 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=")
|
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,6 +1182,28 @@ pub async fn is_active(service: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -1662,6 +1771,24 @@ app:
|
|||||||
assert!(!s.contains("--network-alias"));
|
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]
|
#[test]
|
||||||
fn from_manifest_pasta_omits_network_alias() {
|
fn from_manifest_pasta_omits_network_alias() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -1991,6 +2118,80 @@ app:
|
|||||||
assert!(!network_aliases_changed(new, new));
|
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]
|
#[tokio::test]
|
||||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,80 +1,155 @@
|
|||||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
//! Durable seller-side entitlements for peer-file invoices and on-chain sales.
|
||||||
//!
|
//! Payment records must outlive browser polling, process restarts and invoice
|
||||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
//! expiry: an invoice can settle while the buyer is disconnected.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use anyhow::{Context, Result};
|
||||||
use std::sync::LazyLock;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, Instant};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::Mutex;
|
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
|
static WRITES: Mutex<()> = Mutex::const_new(());
|
||||||
/// to pay an invoice and download, short enough to keep the map small.
|
|
||||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
struct Entitlement {
|
struct Entitlement {
|
||||||
content_id: String,
|
content_id: String,
|
||||||
price_sats: u64,
|
price_sats: u64,
|
||||||
paid: bool,
|
paid: bool,
|
||||||
created_at: Instant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
fn path(data_dir: &Path, token: &str) -> PathBuf {
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
data_dir.join("content-entitlements").join(format!(
|
||||||
|
"{}.json",
|
||||||
/// Drop expired entries. Caller must hold the lock.
|
hex::encode(Sha256::digest(token.as_bytes()))
|
||||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
))
|
||||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
|
||||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
match fs::read(path(data_dir, token)).await {
|
||||||
let mut map = ENTITLEMENTS.lock().await;
|
Ok(bytes) => Ok(Some(
|
||||||
prune(&mut map);
|
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
|
||||||
map.insert(
|
)),
|
||||||
payment_hash.to_string(),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Entitlement {
|
Err(e) => Err(e).context("Reading payment 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The content_id + price an entitlement was issued for, if still live.
|
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
|
||||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
let target = path(data_dir, token);
|
||||||
let mut map = ENTITLEMENTS.lock().await;
|
let dir = target.parent().unwrap();
|
||||||
prune(&mut map);
|
fs::create_dir_all(dir).await?;
|
||||||
map.get(payment_hash)
|
let tmp = target.with_extension("tmp");
|
||||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
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`.
|
/// Save before exposing an invoice/address to the buyer. Never overwrite an
|
||||||
/// This is the gate the content server consults to release a file.
|
/// existing payment or silently rebind its token to another item or price.
|
||||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
pub async fn record_pending(
|
||||||
let mut map = ENTITLEMENTS.lock().await;
|
data_dir: &Path,
|
||||||
prune(&mut map);
|
token: &str,
|
||||||
map.get(payment_hash)
|
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)
|
.map(|e| e.paid && e.content_id == content_id)
|
||||||
.unwrap_or(false)
|
.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 anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
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_DIR: &str = "purchased-content";
|
||||||
const OWNED_INDEX: &str = "owned.json";
|
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))
|
.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 {
|
match fs::read_to_string(index_path(data_dir)).await {
|
||||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
Ok(s) => serde_json::from_str(&s)
|
||||||
Err(_) => OwnedIndex::default(),
|
.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<()> {
|
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||||
let root = owned_root(data_dir);
|
let root = owned_root(data_dir);
|
||||||
fs::create_dir_all(&root)
|
fs::create_dir_all(&root)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("creating {}", root.display()))?;
|
.with_context(|| format!("creating {}", root.display()))?;
|
||||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
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
|
.await
|
||||||
.context("writing owned index")
|
.context("writing owned index")
|
||||||
}
|
}
|
||||||
@@ -98,17 +131,15 @@ pub async fn record_purchase(
|
|||||||
ecash_backend: &str,
|
ecash_backend: &str,
|
||||||
purchased_at: &str,
|
purchased_at: &str,
|
||||||
) -> Result<()> {
|
) -> 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);
|
let path = bytes_path(data_dir, onion, content_id);
|
||||||
if let Some(parent) = path.parent() {
|
atomic_write(&path, bytes)
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("creating {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
fs::write(&path, bytes)
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||||
|
|
||||||
let mut index = load_index(data_dir).await;
|
|
||||||
let entry = OwnedItem {
|
let entry = OwnedItem {
|
||||||
onion: onion.to_string(),
|
onion: onion.to_string(),
|
||||||
content_id: content_id.to_string(),
|
content_id: content_id.to_string(),
|
||||||
@@ -131,6 +162,11 @@ pub async fn record_purchase(
|
|||||||
save_index(data_dir, &index).await
|
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.
|
/// Every item this node owns.
|
||||||
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||||
load_index(data_dir).await.items
|
load_index(data_dir).await.items
|
||||||
@@ -165,3 +201,90 @@ pub async fn read_owned(
|
|||||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
Some((mime, bytes))
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ where
|
|||||||
if !authorized {
|
if !authorized {
|
||||||
if let Some(hash) = invoice_hash {
|
if let Some(hash) = invoice_hash {
|
||||||
if method_accepted(&item.access, "lightning")
|
if method_accepted(&item.access, "lightning")
|
||||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,6 +664,10 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
|
|||||||
start_stopped_app_stacks(data_dir).await
|
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 {
|
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||||
let user_stopped = load_user_stopped(data_dir).await;
|
let user_stopped = load_user_stopped(data_dir).await;
|
||||||
let mut report = RecoveryReport {
|
let mut report = RecoveryReport {
|
||||||
@@ -677,24 +681,27 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
// Healthy members must never acquire a restarting overlay merely
|
||||||
"Recovering stopped {} stack containers after boot",
|
// because the periodic recovery scan ran. Queue existing stopped
|
||||||
stack.name
|
// 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;
|
repair_stack_network_aliases(stack).await;
|
||||||
|
pending_boot_starts_add(pending.iter().cloned());
|
||||||
// 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()),
|
|
||||||
);
|
|
||||||
|
|
||||||
for container in stack.containers {
|
for container in stack.containers {
|
||||||
|
if !pending.iter().any(|name| name.as_str() == *container) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if user_stopped.contains(*container) {
|
if user_stopped.contains(*container) {
|
||||||
info!("Skipping user-stopped container: {}", container);
|
info!("Skipping user-stopped container: {}", container);
|
||||||
continue;
|
continue;
|
||||||
@@ -706,8 +713,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
pending_boot_start_done(container);
|
pending_boot_start_done(container);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Some(_) => {}
|
Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
|
||||||
None => {
|
_ => {
|
||||||
pending_boot_start_done(container);
|
pending_boot_start_done(container);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1534,3 +1541,24 @@ mod installed_concurrency_tests {
|
|||||||
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,30 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
|
|||||||
norm(a) == norm(b)
|
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 {
|
impl IdentityManager {
|
||||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||||
@@ -150,6 +174,30 @@ impl IdentityManager {
|
|||||||
Ok((identities, default_id))
|
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.
|
/// Create a new identity.
|
||||||
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
||||||
let signing_key = SigningKey::generate(&mut OsRng);
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
@@ -966,6 +1014,167 @@ mod tests {
|
|||||||
assert_ne!(default_id, Some(r1.id));
|
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]
|
#[tokio::test]
|
||||||
async fn test_delete_default_shifts() {
|
async fn test_delete_default_shifts() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ mod node_message;
|
|||||||
mod nostr_discovery;
|
mod nostr_discovery;
|
||||||
mod nostr_handshake;
|
mod nostr_handshake;
|
||||||
mod nostr_relays;
|
mod nostr_relays;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod nostr_security_tests;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod port_allocator;
|
mod port_allocator;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
//! to a full chip erase before write.
|
//! to a full chip erase before write.
|
||||||
//!
|
//!
|
||||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
//! 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
|
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
//! 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
|
/// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not
|
||||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
/// board wiring. Require an explicit board until a board-specific identity is
|
||||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
/// available; guessing a Heltec model can write incompatible firmware.
|
||||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
pub fn resolve_flash_board(_info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||||
///
|
None
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum FlashStage {
|
pub enum FlashStage {
|
||||||
Downloading,
|
Downloading,
|
||||||
|
Preparing,
|
||||||
Erasing,
|
Erasing,
|
||||||
Writing,
|
Writing,
|
||||||
Autoinstalling,
|
Autoinstalling,
|
||||||
@@ -101,11 +88,10 @@ const LOG_TAIL_MAX: usize = 200;
|
|||||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
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
|
/// Deadline for preparation and warning threshold for the active flasher.
|
||||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
/// A download can be cancelled safely. An active write retains ownership until
|
||||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
/// its subprocess exits, even after this threshold: reporting an aborted job
|
||||||
/// forever — generous enough to never trigger on a legitimately slow
|
/// while a detached writer continues would let a retry corrupt the device.
|
||||||
/// multi-hundred-MB transfer.
|
|
||||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
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
|
/// 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")
|
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
|
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||||
/// the *entire* request including streaming the response body, which would
|
/// the *entire* request including streaming the response body, which would
|
||||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
/// 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 {
|
struct GithubAsset {
|
||||||
name: String,
|
name: String,
|
||||||
browser_download_url: String,
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
digest: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -322,8 +322,24 @@ struct GithubRelease {
|
|||||||
assets: Vec<GithubAsset>,
|
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
|
/// 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.
|
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||||
pub async fn start_flash_job(
|
pub async fn start_flash_job(
|
||||||
handle: &FlashJobHandle,
|
handle: &FlashJobHandle,
|
||||||
@@ -333,21 +349,44 @@ pub async fn start_flash_job(
|
|||||||
board: FlashBoard,
|
board: FlashBoard,
|
||||||
family: DeviceType,
|
family: DeviceType,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
{
|
// Missing/corrupt tooling must fail before stopping a working radio or
|
||||||
let existing = handle.read().await;
|
// registering a job that can never reach the serial device.
|
||||||
if let Some(job) = existing.as_ref() {
|
if matches!(family, DeviceType::Meshtastic | DeviceType::Meshcore) {
|
||||||
if !job.snapshot().await.done {
|
preflight_esptool().await?;
|
||||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let job = FlashJob::new(board, family, path.clone());
|
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_job = Arc::clone(&job);
|
||||||
let bg_service = Arc::clone(mesh_service);
|
let bg_service = Arc::clone(mesh_service);
|
||||||
let task = tokio::spawn(async move {
|
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
|
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||||
// the listener's hold on the port before touching it. This USED
|
// the listener's hold on the port before touching it. This USED
|
||||||
// TO run synchronously in start_flash_job before the job was even
|
// 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
|
// subsequent mesh.flash-device call failed with "already in
|
||||||
// progress" until the service was restarted). Generous enough that
|
// progress" until the service was restarted). Generous enough that
|
||||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||||
let result = match tokio::time::timeout(
|
let flash = run_flash(
|
||||||
MAX_JOB_DURATION,
|
board,
|
||||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
family,
|
||||||
)
|
&data_dir,
|
||||||
.await
|
&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,
|
Ok(inner) => inner,
|
||||||
Err(_) => Err(anyhow::anyhow!(
|
Err(_) if bg_job.snapshot().await.stage == FlashStage::Downloading => Err(
|
||||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
anyhow::anyhow!("Firmware download exceeded the time limit; radio was not written"),
|
||||||
MAX_JOB_DURATION.as_secs() / 60
|
),
|
||||||
)),
|
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();
|
let succeeded = result.is_ok();
|
||||||
|
|
||||||
@@ -423,7 +476,6 @@ pub async fn start_flash_job(
|
|||||||
bg_job
|
bg_job
|
||||||
.push_log("Flash completed successfully".to_string())
|
.push_log("Flash completed successfully".to_string())
|
||||||
.await;
|
.await;
|
||||||
bg_job.finish().await;
|
|
||||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -434,7 +486,6 @@ pub async fn start_flash_job(
|
|||||||
// erase_flash failed", no actual esptool stderr).
|
// erase_flash failed", no actual esptool stderr).
|
||||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
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 !succeeded {
|
||||||
|
if let Err(error) = &result {
|
||||||
|
bg_job.fail(error).await;
|
||||||
|
}
|
||||||
// Deliberately do NOT auto-restart the listener here. A failed
|
// Deliberately do NOT auto-restart the listener here. A failed
|
||||||
// flash means we can't vouch for the board's state — reopening
|
// flash means we can't vouch for the board's state — reopening
|
||||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
// 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"),
|
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());
|
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||||
|
|
||||||
@@ -510,12 +565,13 @@ async fn run_flash(
|
|||||||
family: DeviceType,
|
family: DeviceType,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
prepared_image: Option<&Path>,
|
||||||
job: &Arc<FlashJob>,
|
job: &Arc<FlashJob>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match family {
|
match family {
|
||||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
let image = prepared_image.context("Firmware was not prepared before serial access")?;
|
||||||
esptool_erase_and_write(path, &image, job).await
|
esptool_erase_and_write(path, image, job).await
|
||||||
}
|
}
|
||||||
DeviceType::Reticulum => {
|
DeviceType::Reticulum => {
|
||||||
let lora_region = super::load_config(data_dir)
|
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::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);
|
let out_path = cache.join(&asset.name);
|
||||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
if verify_meshcore_asset(&out_path, asset).await.is_ok() {
|
||||||
return Ok(out_path);
|
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?;
|
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)
|
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(
|
async fn download_to_file(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
url: &str,
|
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)
|
tokio::fs::rename(&tmp, dest)
|
||||||
.await
|
.await
|
||||||
.context("Finalizing firmware download")?;
|
.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
|
/// Building global args separately from subcommand args keeps this correct
|
||||||
/// by construction instead of relying on call-site ordering.
|
/// by construction instead of relying on call-site ordering.
|
||||||
///
|
///
|
||||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
/// Normal stub-loader mode is required for full-chip erase. The packaged
|
||||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
/// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's
|
||||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
/// stripped esptool package alone did not provide that resource, so changing
|
||||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
/// flags to --no-stub cannot repair this operation.
|
||||||
/// 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.
|
|
||||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||||
if let Some(b) = baud {
|
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
|
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<()> {
|
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(esptool_global_args(path, None));
|
||||||
cmd.args(subcommand);
|
cmd.args(subcommand);
|
||||||
match run_streamed(cmd, None, job).await {
|
match run_streamed(cmd, None, job).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(first_err) => {
|
Err(first_err) if retryable_flash_error(&first_err) => {
|
||||||
job.push_log(format!(
|
job.push_log(format!(
|
||||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||||
))
|
))
|
||||||
.await;
|
.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(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||||
retry.args(subcommand);
|
retry.args(subcommand);
|
||||||
run_streamed(retry, None, job)
|
run_streamed(retry, None, job)
|
||||||
.await
|
.await
|
||||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
.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(())
|
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);
|
let (new_tx, new_rx) = tokio::sync::mpsc::channel(32);
|
||||||
*self.state.cmd_tx.write().await = new_tx;
|
*self.state.cmd_tx.write().await = new_tx;
|
||||||
self.cmd_rx = Some(new_rx);
|
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");
|
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);
|
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||||
self.config = config.clone();
|
self.config = config.clone();
|
||||||
|
|
||||||
@@ -2335,10 +2346,13 @@ impl MeshService {
|
|||||||
.unwrap_or_else(|| "archipelago".to_string());
|
.unwrap_or_else(|| "archipelago".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabled state changed, start/stop the listener
|
// Reconcile desired state with the actual task, not the old enabled
|
||||||
if config.enabled && !was_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()?;
|
self.start()?;
|
||||||
} else if !config.enabled && was_enabled {
|
} else if !config.enabled {
|
||||||
self.stop().await;
|
self.stop().await;
|
||||||
// Clear connected state
|
// Clear connected state
|
||||||
let mut status = self.state.status.write().await;
|
let mut status = self.state.status.write().await;
|
||||||
@@ -2347,7 +2361,7 @@ impl MeshService {
|
|||||||
status.firmware_version = None;
|
status.firmware_version = None;
|
||||||
status.self_node_id = None;
|
status.self_node_id = None;
|
||||||
status.peer_count = 0;
|
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");
|
info!("Mesh session config changed — restarting listener to apply");
|
||||||
self.stop().await;
|
self.stop().await;
|
||||||
self.start()?;
|
self.start()?;
|
||||||
@@ -2537,6 +2551,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn session_config_change_detection() {
|
fn session_config_change_detection() {
|
||||||
let base = MeshConfig::default();
|
let base = MeshConfig::default();
|
||||||
|
|||||||
@@ -378,6 +378,19 @@ fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
|||||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||||
/// Returns firmware version string and device capabilities.
|
/// Returns firmware version string and device capabilities.
|
||||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
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.
|
// Device info format varies by firmware version.
|
||||||
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
||||||
if data.is_empty() {
|
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());
|
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]]);
|
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
|
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||||
@@ -966,4 +988,24 @@ mod tests {
|
|||||||
fn test_parse_self_info_too_short() {
|
fn test_parse_self_info_too_short() {
|
||||||
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
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).
|
/// One peer learned via an RNS announce (LXMF delivery destination).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ReticulumPeer {
|
struct ReticulumPeer {
|
||||||
@@ -517,10 +530,10 @@ impl ReticulumLink {
|
|||||||
let child = cmd
|
let child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
|
.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.
|
// 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
|
// StartingDaemon tears the group down on errors AND cancellation.
|
||||||
// group down via `terminate_group` (the child has no `kill_on_drop`).
|
|
||||||
let init = async {
|
let init = async {
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||||
let stream = loop {
|
let stream = loop {
|
||||||
@@ -560,20 +573,17 @@ impl ReticulumLink {
|
|||||||
dest_hash = %dest_hash_hex,
|
dest_hash = %dest_hash_hex,
|
||||||
"Reticulum daemon ready"
|
"Reticulum daemon ready"
|
||||||
);
|
);
|
||||||
Ok((write_half, reader, dest_hash, display_name))
|
Ok::<_, anyhow::Error>((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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let (write_half, reader, dest_hash, display_name) = init.await?;
|
||||||
|
|
||||||
let mut link = Self {
|
let mut link = Self {
|
||||||
device_path: label,
|
device_path: label,
|
||||||
socket_path,
|
socket_path,
|
||||||
child,
|
child: starting
|
||||||
|
.0
|
||||||
|
.take()
|
||||||
|
.expect("starting daemon is owned until ready"),
|
||||||
writer: write_half,
|
writer: write_half,
|
||||||
reader,
|
reader,
|
||||||
dest_hash,
|
dest_hash,
|
||||||
@@ -1557,6 +1567,33 @@ impl Drop for ReticulumLink {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn announced_name_precedence() {
|
fn announced_name_precedence() {
|
||||||
// Daemon-decoded LXMF name always wins.
|
// Daemon-decoded LXMF name always wins.
|
||||||
|
|||||||
@@ -146,12 +146,16 @@ impl MeshcoreDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let info = DeviceInfo {
|
let mut info = DeviceInfo {
|
||||||
firmware_version: name.clone(),
|
firmware_version: "unknown".to_string(),
|
||||||
node_id,
|
node_id,
|
||||||
max_contacts: 100,
|
max_contacts: 100,
|
||||||
device_type: super::types::DeviceType::Meshcore,
|
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());
|
self.device_info = Some(info.clone());
|
||||||
|
|
||||||
info!("Meshcore initialization complete on {}", self.device_path);
|
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.send".to_string(), (5usize, 300u64));
|
||||||
limits.insert("wallet.ecash-send".to_string(), (10, 300));
|
limits.insert("wallet.ecash-send".to_string(), (10, 300));
|
||||||
limits.insert("lnd.sendcoins".to_string(), (5, 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.payinvoice".to_string(), (10, 300));
|
||||||
limits.insert("lnd.openchannel".to_string(), (3, 300));
|
limits.insert("lnd.openchannel".to_string(), (3, 300));
|
||||||
limits.insert("lnd.closechannel".to_string(), (3, 300));
|
limits.insert("lnd.closechannel".to_string(), (3, 300));
|
||||||
|
|||||||
@@ -1475,6 +1475,48 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
|||||||
|| path.starts_with("/dwn/")
|
|| 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(
|
async fn accept_loop(
|
||||||
handler: Arc<ApiHandler>,
|
handler: Arc<ApiHandler>,
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
@@ -1552,7 +1594,7 @@ async fn accept_loop(
|
|||||||
// forwarded headers on loopback (nginx) connections.
|
// forwarded headers on loopback (nginx) connections.
|
||||||
req.extensions_mut()
|
req.extensions_mut()
|
||||||
.insert(crate::api::rpc::PeerAddr(peer_addr));
|
.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()
|
let resp = hyper::Response::builder()
|
||||||
.status(hyper::StatusCode::NOT_FOUND)
|
.status(hyper::StatusCode::NOT_FOUND)
|
||||||
.body(hyper::Body::empty())
|
.body(hyper::Body::empty())
|
||||||
@@ -2520,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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2059,6 +2059,25 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
let backup_binary = backup_dir.join("archipelago");
|
let backup_binary = backup_dir.join("archipelago");
|
||||||
if backup_binary.exists() {
|
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:
|
// Same two namespace gotchas as apply_update()'s binary swap:
|
||||||
// `cp` straight onto the running binary is O_TRUNC and fails
|
// `cp` straight onto the running binary is O_TRUNC and fails
|
||||||
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
||||||
@@ -2654,6 +2673,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_newer() {
|
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.7.19-alpha", "1.7.18-alpha"));
|
||||||
assert!(is_newer("1.8.0-alpha", "1.7.99-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
|
assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ pub struct ContainerConfig {
|
|||||||
|
|
||||||
/// Derived-env entry. The template is rendered against `HostFacts` at
|
/// Derived-env entry. The template is rendered against `HostFacts` at
|
||||||
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
|
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
|
||||||
/// fact name is allowed (host_ip, host_mdns, disk_gb).
|
/// fact name is allowed (host_ip, host_mdns, disk_gb, bitcoin_host,
|
||||||
|
/// node_identity_pubkeys).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DerivedEnv {
|
pub struct DerivedEnv {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -1428,6 +1429,13 @@ pub struct HostFacts {
|
|||||||
/// right host. Both are reachable on archy-net by their container name;
|
/// right host. Both are reachable on archy-net by their container name;
|
||||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||||
pub bitcoin_host: String,
|
pub bitcoin_host: String,
|
||||||
|
/// Nostr public keys of the node's identities that the app identity
|
||||||
|
/// picker offers for signing (the node's own appliance key excluded),
|
||||||
|
/// as comma-joined, sorted, lowercase 64-char hex. Lets an app grant
|
||||||
|
/// the node's users owner rights (e.g. a Blossom server's allowed
|
||||||
|
/// uploaders). Empty unless a manifest templates it; the orchestrator
|
||||||
|
/// resolves it on demand and refuses to render an empty set.
|
||||||
|
pub node_identity_pubkeys: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostFacts {
|
impl HostFacts {
|
||||||
@@ -1439,13 +1447,20 @@ impl HostFacts {
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: "1111111111111111111111111111111111111111111111111111111111111111,2222222222222222222222222222222222222222222222222222222222222222".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
/// with `HostFacts`. Centralized so validation and rendering agree.
|
||||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
|
const DERIVED_PLACEHOLDERS: &[&str] = &[
|
||||||
|
"HOST_IP",
|
||||||
|
"HOST_MDNS",
|
||||||
|
"DISK_GB",
|
||||||
|
"BITCOIN_HOST",
|
||||||
|
"NODE_IDENTITY_PUBKEYS",
|
||||||
|
];
|
||||||
|
|
||||||
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||||
@@ -1529,7 +1544,8 @@ impl ContainerConfig {
|
|||||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
||||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
|
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host)
|
||||||
|
.replace("{{NODE_IDENTITY_PUBKEYS}}", &facts.node_identity_pubkeys);
|
||||||
format!("{}={}", e.key, value)
|
format!("{}={}", e.key, value)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -2396,6 +2412,46 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_identity_pubkeys_placeholder_is_accepted() {
|
||||||
|
let yaml = r#"
|
||||||
|
app:
|
||||||
|
id: wildbloom-node
|
||||||
|
name: Wildbloom Node
|
||||||
|
version: 0.2.2
|
||||||
|
container:
|
||||||
|
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
|
||||||
|
derived_env:
|
||||||
|
- key: WILDBLOOM_ALLOW_PUBKEYS
|
||||||
|
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||||
|
"#;
|
||||||
|
AppManifest::parse(yaml).expect("NODE_IDENTITY_PUBKEYS is a supported placeholder");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_derived_env_renders_node_identity_pubkeys() {
|
||||||
|
let yaml = r#"
|
||||||
|
app:
|
||||||
|
id: wildbloom-node
|
||||||
|
name: Wildbloom Node
|
||||||
|
version: 0.2.2
|
||||||
|
container:
|
||||||
|
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
|
||||||
|
derived_env:
|
||||||
|
- key: WILDBLOOM_ALLOW_PUBKEYS
|
||||||
|
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||||
|
"#;
|
||||||
|
let manifest = AppManifest::parse(yaml).unwrap();
|
||||||
|
let facts = HostFacts::sample();
|
||||||
|
assert_eq!(
|
||||||
|
manifest.app.container.resolve_derived_env(&facts),
|
||||||
|
vec![format!(
|
||||||
|
"WILDBLOOM_ALLOW_PUBKEYS={}",
|
||||||
|
facts.node_identity_pubkeys
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_traversal_secret_file_is_rejected() {
|
fn path_traversal_secret_file_is_rejected() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -2451,6 +2507,7 @@ app:
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-core".to_string(),
|
bitcoin_host: "bitcoin-core".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let out = c.resolve_derived_env(&facts);
|
let out = c.resolve_derived_env(&facts);
|
||||||
|
|||||||
@@ -450,6 +450,17 @@ impl PodmanClient {
|
|||||||
"nsmode": net_mode
|
"nsmode": net_mode
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if matches!(
|
||||||
|
manifest.app.container.network.as_deref(),
|
||||||
|
Some(
|
||||||
|
"slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
body["network_options"] = serde_json::json!({
|
||||||
|
"slirp4netns": manifest.app.container.network.as_deref().unwrap().split_once(':').unwrap().1.split(',').collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(network) = custom_network {
|
if let Some(network) = custom_network {
|
||||||
// The container always answers to its own name; manifest
|
// The container always answers to its own name; manifest
|
||||||
// network_aliases add extra short hostnames peers may bake in
|
// network_aliases add extra short hostnames peers may bake in
|
||||||
@@ -727,7 +738,11 @@ fn podman_network_settings(
|
|||||||
Some("host") => ("host", None),
|
Some("host") => ("host", None),
|
||||||
Some("bridge") => ("bridge", None),
|
Some("bridge") => ("bridge", None),
|
||||||
Some("none") => ("none", None),
|
Some("none") => ("none", None),
|
||||||
Some("slirp4netns") => ("slirp4netns", None),
|
Some(
|
||||||
|
"slirp4netns"
|
||||||
|
| "slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24",
|
||||||
|
) => ("slirp4netns", None),
|
||||||
Some("pasta") => ("pasta", None),
|
Some("pasta") => ("pasta", None),
|
||||||
Some("private") => ("private", None),
|
Some("private") => ("private", None),
|
||||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||||
@@ -1077,6 +1092,14 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_rootless_options_are_not_a_named_bridge() {
|
||||||
|
assert_eq!(
|
||||||
|
podman_network_settings(Some("slirp4netns:allow_host_loopback=true"), "isolated"),
|
||||||
|
("slirp4netns", None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||||
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ContainerRuntime: Send + Sync {
|
pub trait ContainerRuntime: Send + Sync {
|
||||||
|
/// CLI used for offline app provisioning in this runtime's storage scope.
|
||||||
|
fn cli_name(&self) -> &'static str {
|
||||||
|
"podman"
|
||||||
|
}
|
||||||
|
|
||||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
||||||
async fn create_container(
|
async fn create_container(
|
||||||
&self,
|
&self,
|
||||||
@@ -628,7 +633,13 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or(&manifest.app.security.network_policy);
|
.unwrap_or(&manifest.app.security.network_policy);
|
||||||
if matches!(network, "slirp4netns" | "pasta") {
|
if matches!(
|
||||||
|
network,
|
||||||
|
"slirp4netns"
|
||||||
|
| "slirp4netns:allow_host_loopback=true"
|
||||||
|
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||||
|
| "pasta"
|
||||||
|
) {
|
||||||
anyhow::bail!("this app requires rootless Podman networking ({network})");
|
anyhow::bail!("this app requires rootless Podman networking ({network})");
|
||||||
}
|
}
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
@@ -660,6 +671,10 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ContainerRuntime for DockerRuntime {
|
impl ContainerRuntime for DockerRuntime {
|
||||||
|
fn cli_name(&self) -> &'static str {
|
||||||
|
"docker"
|
||||||
|
}
|
||||||
|
|
||||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||||
// Same signature gate as the podman path — the docker fallback is
|
// Same signature gate as the podman path — the docker fallback is
|
||||||
// dev-only, but a declared signature must never be skippable by
|
// dev-only, but a declared signature must never be skippable by
|
||||||
@@ -991,6 +1006,10 @@ impl AutoRuntime {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ContainerRuntime for AutoRuntime {
|
impl ContainerRuntime for AutoRuntime {
|
||||||
|
fn cli_name(&self) -> &'static str {
|
||||||
|
self.runtime.cli_name()
|
||||||
|
}
|
||||||
|
|
||||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||||
self.runtime.pull_image(image, signature).await
|
self.runtime.pull_image(image, signature).await
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -22,7 +22,9 @@ To pick up a new build, redeploy the stack (or wire the CI Portainer webhook).
|
|||||||
The images are built from the Archipelago monorepo by
|
The images are built from the Archipelago monorepo by
|
||||||
`.github/workflows/demo-images.yml` on every change to `neode-ui/`, tagged `:demo`
|
`.github/workflows/demo-images.yml` on every change to `neode-ui/`, tagged `:demo`
|
||||||
and `:<git-sha>`, and pushed to `REGISTRY`. Editing the real UI → CI rebuilds →
|
and `:<git-sha>`, and pushed to `REGISTRY`. Editing the real UI → CI rebuilds →
|
||||||
redeploy here. No source lives in this repo.
|
redeploy here. The optional Portainer webhook runs only on an explicit workflow
|
||||||
|
dispatch after release qualification; a source push alone does not invoke it.
|
||||||
|
No source lives in this repo.
|
||||||
|
|
||||||
## What's mocked
|
## What's mocked
|
||||||
|
|
||||||
@@ -31,3 +33,18 @@ redeploy here. No source lives in this repo.
|
|||||||
- **Wallet/Bitcoin** — signet-flavored; use the in-UI faucet for test sats.
|
- **Wallet/Bitcoin** — signet-flavored; use the in-UI faucet for test sats.
|
||||||
- **Files** — real per-session upload/rename/delete, 50 MB quota, wiped on reap.
|
- **Files** — real per-session upload/rename/delete, 50 MB quota, wiped on reap.
|
||||||
- **Intro** — replays once per calendar day per browser.
|
- **Intro** — replays once per calendar day per browser.
|
||||||
|
|
||||||
|
## Building a reviewed demo revision
|
||||||
|
|
||||||
|
The web image builds both the dashboard and AIUI from the checked-out source;
|
||||||
|
it does not use the historical `demo/aiui` bundle. CI supplies the full commit
|
||||||
|
as `SOURCE_REVISION`. For a local source build, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SOURCE_REVISION=$(git rev-parse HEAD) docker compose -f docker-compose.demo.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
AIUI uses its frozen dependency lockfile, type checking and the canonical
|
||||||
|
`/aiui/` build verifier. Its `BUILD-INFO` identifies that exact source revision.
|
||||||
|
Prepare and test the images before changing the public demo stack; retain the
|
||||||
|
previous image IDs for rollback.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
dockerfile: neode-ui/Dockerfile.web
|
dockerfile: neode-ui/Dockerfile.web
|
||||||
args:
|
args:
|
||||||
VITE_DEMO: "1"
|
VITE_DEMO: "1"
|
||||||
|
SOURCE_REVISION: ${SOURCE_REVISION:?Set SOURCE_REVISION to git rev-parse HEAD}
|
||||||
container_name: archy-demo-web
|
container_name: archy-demo-web
|
||||||
ports:
|
ports:
|
||||||
- "2100:80"
|
- "2100:80"
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||||
|
|
||||||
|
## NPM certificate release blocker — reported 2026-10-01
|
||||||
|
|
||||||
|
- [ ] **NPM first certificate and renewal must work without manual repair.**
|
||||||
|
Shorty's HTTP-01 404 exposed inconsistent active data/ACME paths between the
|
||||||
|
manifest, legacy creation paths and host nginx bridge. Independent reviewer
|
||||||
|
acknowledged the handoff; separate release owner must acknowledge receipt,
|
||||||
|
implement data-safe migration and complete fresh-install, legacy-upgrade,
|
||||||
|
issuance/renewal, restart/reboot and OTA/ISO acceptance. See
|
||||||
|
[repair evidence and required release tests](npm-certificate-handoff-20261001.md).
|
||||||
|
Do not equate the live challenge-route repair with a tested release fix.
|
||||||
|
|
||||||
## Framework incident — closed with operator acceptance
|
## Framework incident — closed with operator acceptance
|
||||||
|
|
||||||
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Angor client acceptance and remaining project recovery
|
||||||
|
|
||||||
|
Status: **OPEN — live services and one complete project flow pass; full project recovery is incomplete.**
|
||||||
|
|
||||||
|
The operator requires actual Angor-client verification and any necessary application
|
||||||
|
fixes to reach the next OTA, app catalog and ISO. Main release owner acknowledged
|
||||||
|
ownership of NPM/public management isolation; this investigation owns Angor evidence,
|
||||||
|
app setup documentation and the scoped browser test. Do not treat this report as
|
||||||
|
permission to waive the outstanding discovery/recovery requirements.
|
||||||
|
|
||||||
|
## Verified live behavior
|
||||||
|
|
||||||
|
- Trusted HTTPS indexer health, fees, address transactions and cursor pagination work.
|
||||||
|
- Real browser requests from `https://angor.io` reach the selected custom indexer;
|
||||||
|
checked transaction IDs, confirmation data and output scripts match the reference.
|
||||||
|
- The indexer serves all 35 funding transactions listed in the public reference
|
||||||
|
snapshot, confirmed, with matching original announcement commitments. This checks
|
||||||
|
that snapshot, not every possible Bitcoin address or the entire project universe.
|
||||||
|
- Relay NIP-11 and encrypted WebSocket/Nostr read work. An invalid signature was
|
||||||
|
rejected before importing the valid original announcement; the original signed
|
||||||
|
announcement was accepted unchanged and read back exactly.
|
||||||
|
- Imported ONLY the original public kind3030 event
|
||||||
|
`adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834`, after checking its
|
||||||
|
Schnorr signature with nostr-tools and matching funding transaction
|
||||||
|
`72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7` on our indexer.
|
||||||
|
No fabricated, re-signed or modified announcement, wallet operation or payment.
|
||||||
|
- Restarted only the managed Angor relay. Its original announcement persisted;
|
||||||
|
node-internal strfry container ID and start time remained unchanged.
|
||||||
|
- Clean Chromium, configured with our indexer plus original relays and our relay,
|
||||||
|
now displays **Casa Bitcoin sv** in Explore and loads its full project page,
|
||||||
|
metadata, funding transaction and Project Statistics.
|
||||||
|
- `tests/lifecycle/angor-public-browser.cjs` passes all three acceptance groups:
|
||||||
|
trusted public API/WSS and chain commitment; actual Explore discovery;
|
||||||
|
actual project details/statistics. It is opt-in and read-only, uses a disposable
|
||||||
|
profile and known immutable public fixture, and does not disable TLS checks.
|
||||||
|
|
||||||
|
Live test command (run from repository root):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ANGOR_TEST_INDEXER=https://angor-indexer.tx1138.com/ \
|
||||||
|
ANGOR_TEST_RELAY=wss://angor-relay.tx1138.com/ \
|
||||||
|
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay2.angor.io","wss://angor-relay.tx1138.com"]' \
|
||||||
|
node tests/lifecycle/angor-public-browser.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty project list: reproduced upstream behavior
|
||||||
|
|
||||||
|
The user retained the original relays. Our earlier suggestion that an empty new
|
||||||
|
relay explained the entire failure was incorrect.
|
||||||
|
|
||||||
|
Angor Hub v2.0.0 (`main-575CWKJX.js`) was tested in separate clean Chromium profiles
|
||||||
|
using our indexer and `https://indexer.angor.io/`. Both received candidate Nostr
|
||||||
|
announcements, but the current batch failed validation: `event-mismatch` for
|
||||||
|
updated announcements of one funded project, and `not-found-in-mempool` for another
|
||||||
|
address that both indexers correctly return empty. Both displayed zero projects.
|
||||||
|
The default primary `fulcrum.angor.online` separately failed browser CORS in a
|
||||||
|
fresh default-config test. These upstream failures must not be attributed to
|
||||||
|
missing data in our indexer without evidence.
|
||||||
|
|
||||||
|
Current `block-core/angor-hub` `src/app/services/indexer.service.ts` discovers
|
||||||
|
kind3030 events, filters by network, and calls `validateAndAddProjects` in the
|
||||||
|
background. When a batch has candidates but none validate, it does not continue
|
||||||
|
discovery to older valid original announcements. The Load More UI is unavailable
|
||||||
|
when the project list is empty. An empty relay can coexist with this bug but is
|
||||||
|
not its sole cause. After the authentic announcement was copied to our relay,
|
||||||
|
one original passed the unchanged validation and appeared. This is a bounded
|
||||||
|
history-availability recovery, NOT an upstream UI fix or global relay sync.
|
||||||
|
|
||||||
|
The current browser uses ordinary `/api/v1/address/.../txs`, not the deprecated
|
||||||
|
`/api/v1/query/Angor/projects` listing. Our adapter's 404 for the latter is NOT the
|
||||||
|
observed browser failure. Do not add an opaque upstream proxy to hide that 404.
|
||||||
|
|
||||||
|
## Other projects remain unresolved
|
||||||
|
|
||||||
|
The public reference query returned 35 project records (limit50). All35 on-chain
|
||||||
|
funding commitments were verified through our indexer. Exact original-event-ID
|
||||||
|
queries were sent to the configured relays `relay.angor.io`, `relay2.angor.io`,
|
||||||
|
`nos.lol`, `relay.primal.net`, and `relay.damus.io` (Damus had an initial connection
|
||||||
|
failure, then answered EOSE on retry). Only one original announcement was found.
|
||||||
|
Additional queries to `relay.snort.social`, `nostr.mom`, and `no.str.cr` returned
|
||||||
|
no matches; `relay.nostr.band` and `relay.f7z.io` were unavailable. This does not
|
||||||
|
prove the other34 events are globally lost or that all storage sources were checked.
|
||||||
|
|
||||||
|
Exact project IDs, transaction IDs, event IDs and confirmed commitment results:
|
||||||
|
[recovery inventory](angor-project-recovery-20261001.json). Recover originals from
|
||||||
|
founders/known archives or authoritative relay backups and retain their signatures.
|
||||||
|
On-chain commitments cannot reconstruct missing off-chain project content.
|
||||||
|
Do not fabricate replacements or accept mismatched events to make cards appear.
|
||||||
|
|
||||||
|
## Release handoff and open gates
|
||||||
|
|
||||||
|
- [x] Release owner acknowledges these final findings and the 34-project gap.
|
||||||
|
Confirmed 2026-10-01 18:46:50 UTC in `/tmp/angor-final-handoff-ack.txt`: full
|
||||||
|
recovery remains open; publication held for required gates; preserve relay
|
||||||
|
data and rerun browser acceptance after bridge migration and OTA.
|
||||||
|
- [ ] Resolve or explicitly carry the upstream discovery bug with accurate user
|
||||||
|
guidance; do not claim full recovery because the fixture passes.
|
||||||
|
- [ ] Locate and verify remaining original project metadata/history, or obtain an
|
||||||
|
explicit operator scope decision; one project is not complete acceptance.
|
||||||
|
- [ ] Preserve the verified public relay event/data through the candidate upgrade.
|
||||||
|
- [ ] Repeat scoped browser acceptance after NPM bridge migration and OTA install.
|
||||||
|
- [ ] Verify fresh ISO setup, NPM host propagation, public IP/default-host isolation,
|
||||||
|
certificate issuance/renewal and relay WebSocket routing; see NPM handoff.
|
||||||
|
- [ ] Include updated app setup documentation and the read-only acceptance test.
|
||||||
|
No Angor image/config change was justified by the verified transaction data;
|
||||||
|
no image or catalog version should be bumped solely to disguise upstream failure.
|
||||||
|
- [ ] Existing signed transaction-broadcast integration tests remain required;
|
||||||
|
this live investigation did not send real Bitcoin or test a funded investment.
|
||||||
|
|
||||||
|
Raw local diagnostic logs are in `/tmp/angor-*-browser*.log` and
|
||||||
|
`/tmp/angor-public-browser-acceptance.log`. The durable facts and recovery inventory
|
||||||
|
above must remain available after temporary logs expire.
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
{
|
||||||
|
"status": "INCOMPLETE: 34 original announcements not recovered from queried relays",
|
||||||
|
"source_inventory": "https://indexer.angor.io/api/v1/query/Angor/projects?limit=50",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"project": "angor1qryhse38vcyqnp0j6976q9f00a9jpj2ary03nlc",
|
||||||
|
"txid": "72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7",
|
||||||
|
"event_id": "adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q3eh4xg7t2hge7ctqk4yhmj7q23t6mdqa0ahg28",
|
||||||
|
"txid": "f5ff2e1a6b7340ddb9944c2ae6477c6b0b12993c9919f6e9b7b9e7a5e9a68f6d",
|
||||||
|
"event_id": "f4417e39c9b47afafd84cdda2017207e0929df2852badbf8d909bf6f647f4f2d",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qde7y830vtajref5vwag5x459m9w5y75gd49v4t",
|
||||||
|
"txid": "205ce39688572ef0168c1c1231c25ec632abb2c2b64c52fccb4f53eb0a49e300",
|
||||||
|
"event_id": "ca76084c2bc3301f8fe00dd8a93c6d6c0ad015e50676cdd3e44c8bd765f157d5",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q9k9976ytwkkmswlq24e89l2fxuxl57gfp8yxgr",
|
||||||
|
"txid": "00a0120818698ef3d72d360e29e9e4e7d0a35c180967186d1e715aef797c26a8",
|
||||||
|
"event_id": "a04d1eed8c1261cb5fca6a6faf16a5f87a9672cf40d1117b25f2da60e6e8f84c",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q67h8cktwy8yv32t0uk0c8zrtpmtwgtd56sylzx",
|
||||||
|
"txid": "1bfa726353a40d5fce1a76ad86dc7915bee214573d30d5751cd6312c94519089",
|
||||||
|
"event_id": "b3189f84b490da422e17b6b857d393b13781bb82e1c8813328769e3d2840098a",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qm999ekmrdjl4sq3qywl889w74qetxe3ghdxthl",
|
||||||
|
"txid": "ae9d471dd6e58b318120fbdf72929c621cf23d9c6f047df3c57923a8a915d01c",
|
||||||
|
"event_id": "89ae5e612e857f89dbbdc662198b894f1f7b70564769bedf08ec37f5e7b5efe0",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z",
|
||||||
|
"txid": "c2cb77ebf6b9ded802b677c600c3869b81fc76f31a3e4742b6a9cb16e0ac3dee",
|
||||||
|
"event_id": "f5e66707f8fa0fc601bef403020e64d9a164d6cf201599ec0c3ddb0acf58491e",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qu8h0ezv596z35uggg0r0ppkma93tnreyjg5du7",
|
||||||
|
"txid": "fb6bc0b721810957ae8910d5dd6e7ebc45d804a3e1f636d153df50f036e90645",
|
||||||
|
"event_id": "ce61a11b79aa258238db63f67472341169944bce02600045bf7278992aeb796c",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q8u9c2h2l0eqjn3qeyvdps89dkkvwteg5q3m708",
|
||||||
|
"txid": "04ed05297e146206c104c7b5d8a51fc3453f1829950675b4694f91a7ea609be1",
|
||||||
|
"event_id": "f31c6ef3a66e74ebfc26f5b2905e6d4641f73bdb407aa92022a2202b9be54716",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qqnxefpr4f59r5f4u34c7crzst2ya83wavh8407",
|
||||||
|
"txid": "bae879110e78ad44624980ea4a47de21b03d3994139714bb09e910187027583d",
|
||||||
|
"event_id": "cbd23077098059c5092bb7ea8df14dd73f21d47d1b690ff1e6fa789ad118864b",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qfydf802v2efvxufevrfg0mvtlrxln63rkzmdxw",
|
||||||
|
"txid": "4bbb04fed973c968e76faaca880197092be288d9897072ab5e6dfb719c19eb69",
|
||||||
|
"event_id": "e9761e1303de7eca0ade33936489d2b4b7d51f4b52e8ecbc1acfc394df48b95d",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qpt96uj5jg77q5566pmcdggyuztt26xn3xymwhn",
|
||||||
|
"txid": "bc46fea91acf4714f5b7949e9bb937f39e425468242c2459b581a14914e641b4",
|
||||||
|
"event_id": "f683c12dbc3d574797bd2251d7e9d96e1d33d6263f75a1538d2ce5fd0c86e064",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q3nleprspa3h6thy9c25wzu4q7ywajhcdg3j9au",
|
||||||
|
"txid": "7b76a8297f16ad5ff3d69fc85e37405ad4f77a71dfc8f70206a5a9c1827698ba",
|
||||||
|
"event_id": "893e5b7f09a12368f208120deb9c02b3692aee15240a914fb428e7e4f86b1123",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qc8jlugwgp90vzkhf336d8exldhwd8z5u4ssaen",
|
||||||
|
"txid": "5e06eb3684bf0d3402d20d643d6fe1b5a295680a29db440e070a89f80e52a241",
|
||||||
|
"event_id": "375eafd0e03c50d683de4f465501e919a8816ae618ca2b5c14e8d3f56daa90c0",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qdhwn7s0p8wcr9kw2932m448y6hd8ywl005fdwu",
|
||||||
|
"txid": "7cc5bff45f0b5e9ae81cadaf787dd4d4680387ab9fddedf8438eae8f87ef34ec",
|
||||||
|
"event_id": "6c7767eaf6ee0ae4ffe4b23c8477f2293216279e5908dd979abd4abdf1557578",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q8qzylut0gv7urycxukdfumzw6znghrk4g928k2",
|
||||||
|
"txid": "2c7eed8d9b8eef530a4a8f39339f7dfaa82d5ec09261305203d1a367a624be1b",
|
||||||
|
"event_id": "24323aae4bca910ddb48544b788860119c3cc13ac19b24694221f1ba2521cd6b",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qzwrm6hf5l0tgen99anr9hj7ylk38v6zhmnq7w0",
|
||||||
|
"txid": "934c45244c283ff24834bab7d01a0964192433cd2bc11143fc5e590b2b954deb",
|
||||||
|
"event_id": "6cfae243c276a602ce2da33842ad930019d75e80ffe81b4cd84b1b187830bba7",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qpdp5d2ahknhtr7kawsl62m7y4hktsfn4uwg6r3",
|
||||||
|
"txid": "dc85d36b324ff829a4d49a606573e136fbc29498bc707d95f5bd0d9ef49956d0",
|
||||||
|
"event_id": "c6d22deef6d1babc99716746f50509e5413b97445d991a0869f0567b7301fc97",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q9v88fe2q2efr4z57wed4gklmkh7trmx6usac0d",
|
||||||
|
"txid": "b98b23e49630f92ac0dedb7e0a8ac5ad2c51f813039f2d9dd708c9a0861b2ca2",
|
||||||
|
"event_id": "e8f518eaed658e9331b3a3feba49f36eda5eb7fe68c81c7b3f71057844729bcb",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q0gtztyk24ea2028ewfn026838k3ll322qrvrhu",
|
||||||
|
"txid": "31b38cf48cf18936b7c370ee72e8bda6e9ee40f24ec676a85b9b8b1abefebdd4",
|
||||||
|
"event_id": "fe34db328e51cc10f9c4d035b0f0aab7f9137a4cdee3a2afc4c6610404e79fe4",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q65fuwpyvek3fxk5p757zyknjd9k9sava3fd98j",
|
||||||
|
"txid": "0b7bf7b9119157edc778bcdc7088b70a439fa5dc8c46e839b95fd2f0a8fbf046",
|
||||||
|
"event_id": "991d0f7d1c261e4d79507238e3d5e4b3d3adce267cf40107ee2ade32485f5cf6",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qjqfp23ac4s4llgcgs3qnenjhpasgrmvt373usp",
|
||||||
|
"txid": "8b4887aba04b12729e609658b0fdbb1e6f1b172ba10360c7a5261c3e9bd590f9",
|
||||||
|
"event_id": "254910567be531d7862afcbcc80b490ee92a5f9ed801424f3d56108d9a114b80",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qfyzk83tqznc3aqg5ly0cewn7ytsrwl474v4dyn",
|
||||||
|
"txid": "3e2ea870b17eab2023a04dca267c45d2c53a41abb3f20b206e095fcbba6c5f02",
|
||||||
|
"event_id": "1e27aa63f276048ba78ef73f69dc5045441feafaa090aff9b995341883f22fb4",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qw7m67a5r6vpmtw2jqpsp4xtxvtu6mmnkgj6gxw",
|
||||||
|
"txid": "f3d3549b2a30c78e7c3b51bc9122e0ae8a7ecb378f9b3564ebf931769637df49",
|
||||||
|
"event_id": "fc4289a4888bfe157588e507204d93723a8c07074cde37bb98cf866793f81c98",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qgdc07hkk0l4kntg2k5fczk7tcx0qeqqx2saru0",
|
||||||
|
"txid": "c1332c51da2c5706f6fc74e3275856438304a7761ced4dcd2e738a7e3c62bd2f",
|
||||||
|
"event_id": "ae1d1f7f883907dea36902fc2dc78c8ad81d22bd59bd7293ba1725c6cc959846",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qhklyl9mw5zwzwynxv6ekaz5f9h5zv3wnd8dn2c",
|
||||||
|
"txid": "be637cd8a23c80f49195345eca5d4c29bf4a9fa0600e0625af702444e300d218",
|
||||||
|
"event_id": "78a1443b14cb252b2510925263889e8c8f40b12fd911d137d63b1dbc2b841a3a",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q4heck5tf6svq7x8jp5twak329xtv9805xppvq5",
|
||||||
|
"txid": "509db524eba0b90e8607396e9eb42ed379a270f3aa602a0bc16eec050c959823",
|
||||||
|
"event_id": "dec392d7d962e7dfd2c9eaa2b05dfc03c909d87982c4d73b9f321dc13487622f",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qc6gykcw82hu3pa4pn94gfxwgpp8dewlsh45rwc",
|
||||||
|
"txid": "087390128a78270cd1465656e3ab63b9b47982dca1a910b7a5664f67f8ab9970",
|
||||||
|
"event_id": "cf634987daa4ee17bb2d160ddb04ce56a0d73e664b9822e5e9a8edf870630a9c",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qfzxd8n94r592gt3mrmgwe7knk6dhajmutpuhkx",
|
||||||
|
"txid": "29cb361fe78b6f199f169b7b4a7d9663b7e7f46607f382ddcf8cb2224a6023f1",
|
||||||
|
"event_id": "ca7a6e0bb27beb46fb8e709378908feb5357e7edd43a9262318cadf72da5f141",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q0whhl3qgq28g443h9mj6dl8n2ssshm6hkd3xse",
|
||||||
|
"txid": "4d70fba03ec51d87df7df16498a95a907b9cff00035455e3ab8242935f260030",
|
||||||
|
"event_id": "3a19b34d76ec7b99eb98ed299737c06cd3710268febf7d2acf6ce1fece9ba459",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1q2a5m2zcwpmkh49z05pg6gd9cxm4dhx3ywfclem",
|
||||||
|
"txid": "5da06a119db273bbb7c64e2cc103a8edb48a9934ab9b5b972ae55f697023ce07",
|
||||||
|
"event_id": "8c3a8dcaf9c55e7797cf4ff9a25b0e7cd7dcbe78c8ed248d53a49c70f9ea4a6b",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qq9ngpm2w8xss5sf0amt63z076y2x885wh0jfv7",
|
||||||
|
"txid": "7f42da75e4b3dd92f9870aefa177c2fb3783f189996abf193b3b353ee21e805e",
|
||||||
|
"event_id": "2f0c6e3b29a74be45033062742b3fbac4a4c7b7be4eeecc7021df4a8b1b195cf",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qwdgxjuzhjykgpn5q8p3l2q9vyrgqdlrkfp5sjr",
|
||||||
|
"txid": "c15d07fd14d58e7204889ba24fe47a9b86aa7bea9992d30f4be36864e7634725",
|
||||||
|
"event_id": "733b28b35f771839bc719125af94916a5400675185d4a75edb09645c8eb4cc24",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qyuj8z532tnhy7srutwecu3j789z22peu2t8c7v",
|
||||||
|
"txid": "821193743f7ca7b0b178a78379d79f5783d874a182a8bf36b70a0a2a6cb12d24",
|
||||||
|
"event_id": "56fce837c628728953138ca57895c7ef9533640a989934c432d1040808781b9f",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "angor1qznva9wmw3anr7qdhhs4v0xptd0pz07gdr2fuwz",
|
||||||
|
"txid": "187e39a0ba9714ae3543bbe775dd9fff9c7a4ac946ce0a850480c3871de287fe",
|
||||||
|
"event_id": "baff907b6f1fb97893e63e1bef6919f819c1b6e37560ed2d4255b77cc26d08cd",
|
||||||
|
"confirmed": true,
|
||||||
|
"commitment_matches": true,
|
||||||
|
"original_announcement_recovered": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ app:
|
|||||||
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
||||||
| `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected |
|
| `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected |
|
||||||
| `app.container.entrypoint` / `custom_args` | Entrypoint and command override |
|
| `app.container.entrypoint` / `custom_args` | Entrypoint and command override |
|
||||||
| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly |
|
| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly. `{{NODE_IDENTITY_PUBKEYS}}` is the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved when the app is installed or started, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value |
|
||||||
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>`, injected as podman secrets (never visible in `podman inspect` or unit files) |
|
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>`, injected as podman secrets (never visible in `podman inspect` or unit files) |
|
||||||
| `app.container.generated_secrets` | Secrets the orchestrator creates on first use (`hex16`/`hex32`/`base64`/`bcrypt`) — self-healing, 0600, no host provisioning |
|
| `app.container.generated_secrets` | Secrets the orchestrator creates on first use (`hex16`/`hex32`/`base64`/`bcrypt`) — self-healing, 0600, no host provisioning |
|
||||||
| `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts |
|
| `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts |
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Exactly **one** of `image` or `build` must be present (image XOR build).
|
|||||||
| `network_aliases` | list of string | Extra DNS names on `network` (podman `--network-alias`) — lets stack members answer to short baked-in hostnames (`api`, `minio`, `relay`). |
|
| `network_aliases` | list of string | Extra DNS names on `network` (podman `--network-alias`) — lets stack members answer to short baked-in hostnames (`api`, `minio`, `relay`). |
|
||||||
| `entrypoint` | list of string | Entrypoint override. |
|
| `entrypoint` | list of string | Entrypoint override. |
|
||||||
| `custom_args` | list of string | Extra positional args appended after the image. |
|
| `custom_args` | list of string | Extra positional args appended after the image. |
|
||||||
| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). Never hard-code host specifics. |
|
| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). `{{NODE_IDENTITY_PUBKEYS}}` resolves to the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved at install and on every start, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value. Never hard-code host specifics. |
|
||||||
| `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/<secret_file>` and injected as a **podman secret**, so it never appears in `podman inspect` or unit files. `secret_file` must be a bare filename (no `/`, no `..`). |
|
| `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/<secret_file>` and injected as a **podman secret**, so it never appears in `podman inspect` or unit files. `secret_file` must be a bare filename (no `/`, no `..`). |
|
||||||
| `generated_secrets` | list | `- { name, kind }` — orchestrator materialises the secret on first use (0600, rootless service user, idempotent + self-healing). `kind ∈ hex16 | hex32 | base64 | bcrypt` (bcrypt writes `<name>` = hash and `<name>.pw` = plaintext). |
|
| `generated_secrets` | list | `- { name, kind }` — orchestrator materialises the secret on first use (0600, rootless service user, idempotent + self-healing). `kind ∈ hex16 | hex32 | base64 | bcrypt` (bcrypt writes `<name>` = hash and `<name>.pw` = plaintext). |
|
||||||
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
||||||
@@ -322,3 +322,14 @@ automatically install dependencies, alter Bitcoin pruning, or require a synced
|
|||||||
backend merely to recognize an already-installed service. Declare ongoing
|
backend merely to recognize an already-installed service. Declare ongoing
|
||||||
relationships separately in `dependencies`; use the app health check for actual
|
relationships separately in `dependencies`; use the app health check for actual
|
||||||
API readiness. Self-dependencies and malformed ids are invalid.
|
API readiness. Self-dependencies and malformed ids are invalid.
|
||||||
|
|
||||||
|
### App owner identity placeholder
|
||||||
|
|
||||||
|
`{{NODE_IDENTITY_PUBKEYS}}` is opt-in per manifest. It gives the application the
|
||||||
|
comma-separated public keys of all user identities offered by the app signer,
|
||||||
|
excluding the appliance identity. It grants no signing capability or private
|
||||||
|
keys. Describe any resulting owner/upload privileges in the app documentation: a
|
||||||
|
shared list also links those identities to the same installation. An absent node
|
||||||
|
key, malformed owner key or empty owner set fails setup rather than rendering an
|
||||||
|
empty allow-list. Do not use this as an anonymous or per-profile authorization
|
||||||
|
mechanism. Existing manifests that omit it keep their existing configuration.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Private signed-catalog qualification
|
||||||
|
|
||||||
|
Use this only on explicitly selected development/acceptance nodes. It permits
|
||||||
|
testing a release-root-signed catalog before fleet publication. It does not
|
||||||
|
publish an app image, change the update mirrors, replace the trust anchor, or
|
||||||
|
authorize an unsigned catalog.
|
||||||
|
|
||||||
|
Set `ARCHY_APP_CATALOG_CANDIDATE` in a management-service systemd drop-in to an
|
||||||
|
absolute local catalog path. Keep that file readable by the service and outside
|
||||||
|
temporary storage if testing reboot persistence. The file must be at most 4 MiB
|
||||||
|
and carry a signature verified against the configured release-root anchor.
|
||||||
|
Malformed, missing, unsigned, tampered and wrong-key candidates fail before
|
||||||
|
replacing the previous cached bytes. An invalid explicitly selected candidate
|
||||||
|
does not fall back to the public catalog. The previous cache remains available;
|
||||||
|
inspect the refresh error rather than assuming the candidate was accepted.
|
||||||
|
|
||||||
|
Before activation, record the exact candidate hash, service binary hash, native
|
||||||
|
Bitcoin/LND identities and start times, app configuration, and existing catalog
|
||||||
|
cache/drop-ins. Back up persistent state before any app runtime migration.
|
||||||
|
Verify the candidate signature with `archipelago ceremony verify PATH` and
|
||||||
|
retain the original signed bytes. A private signing ceremony is not publication
|
||||||
|
approval.
|
||||||
|
|
||||||
|
After management restart, verify:
|
||||||
|
|
||||||
|
- Cached bytes exactly equal the signed candidate and still verify.
|
||||||
|
- Desired app manifests select the expected capability-compatible variant.
|
||||||
|
- Changed apps migrate through their supported lifecycle, with state backups.
|
||||||
|
- Native wallets, intentionally stopped/uninstalled apps and unrelated services
|
||||||
|
retain their previous state.
|
||||||
|
- App requests succeed from the actual caller/container namespace; container
|
||||||
|
health alone is insufficient.
|
||||||
|
- Repeated reconciliation, app restart, and separately arranged node reboot
|
||||||
|
preserve routing, state, certificates and management isolation.
|
||||||
|
|
||||||
|
The setting intentionally pins catalog selection. Track its removal as part of
|
||||||
|
release completion: after the tested catalog is published and verified, remove
|
||||||
|
only the qualification drop-in, reload systemd, restart management, and confirm
|
||||||
|
the normal public refresh returns the expected signed catalog. Do not leave the
|
||||||
|
override behind to silently prevent future app updates. For an aborted test,
|
||||||
|
restore the reviewed previous catalog/runtime/configuration together; removing
|
||||||
|
the override alone can reintroduce older manifest settings.
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
# Next OTA and raw ISO after 1.8.21
|
# Next OTA and raw ISO after 1.8.21
|
||||||
|
|
||||||
**Status: implementation and acceptance in progress; NOT ready to release.**
|
**Status: COMPLETE — 1.8.22-alpha OTA, compatible signed app catalog and raw ISO published on Git and ngit on 2026-10-01; artifact signatures, public downloads and fleet feed verified. Angor full-chain indexing still awaits dev Bitcoin synchronization.**
|
||||||
|
|
||||||
|
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
|
||||||
|
The chronological notes below retain earlier failures and superseded candidates;
|
||||||
|
the final tested source is `6d5f3ffb`.
|
||||||
|
|
||||||
This is the consolidated execution checklist for the operator's chat requests.
|
This is the consolidated execution checklist for the operator's chat requests.
|
||||||
A targeted node repair is not completion of the release. Finish the remaining
|
Release acceptance and publication are complete, with live wallet and app data
|
||||||
acceptance gates, preserve live wallets and app data, and publish both artifacts
|
preservation checks documented below. No universal absence of future failures
|
||||||
through git and ngit. No universal absence of future failures is claimed.
|
is claimed.
|
||||||
|
|
||||||
## Changes already shipped in 1.8.21 or earlier
|
## Changes already shipped in 1.8.21 or earlier
|
||||||
|
|
||||||
@@ -31,12 +35,12 @@ See the Framework incident and 1.8.21 execution records for evidence/limits.
|
|||||||
|
|
||||||
| Task | Implemented/verified | Remaining before release |
|
| Task | Implemented/verified | Remaining before release |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
|
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Final UI/build checks passed |
|
||||||
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
|
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final candidate lifecycle, hard-refresh and stability checks passed |
|
||||||
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
|
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | OTA and ISO build-context/content checks passed |
|
||||||
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts |
|
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; included in signed artifacts |
|
||||||
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and release checks remain |
|
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and integration checks passed |
|
||||||
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync |
|
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/API, lifecycle and catalog checks passed; real indexing on dev waits for Bitcoin sync |
|
||||||
|
|
||||||
Durable payment receipts after a lost seller response remain a separately
|
Durable payment receipts after a lost seller response remain a separately
|
||||||
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
||||||
@@ -45,21 +49,23 @@ completed. See PR review for the accepted scope and coverage limits.
|
|||||||
|
|
||||||
## Final release checklist
|
## Final release checklist
|
||||||
|
|
||||||
- [ ] Finish all new-scope implementation and specific acceptance above.
|
- [x] Finish new-scope implementation and release acceptance; full-chain Angor
|
||||||
|
indexing still depends on the dev node finishing initial sync.
|
||||||
- [x] Remove disposable fixtures and temporary test overrides; verify native
|
- [x] Remove disposable fixtures and temporary test overrides; verify native
|
||||||
Bitcoin/LND identity and start-state baselines remain protected.
|
Bitcoin/LND identity and start-state baselines remain protected.
|
||||||
- [x] Commit and push completed source changes to git and ngit.
|
- [x] Commit and push completed source changes to git and ngit.
|
||||||
- [ ] Run final backend/UI/regression/release gates on the final source; inspect
|
- [x] Run final backend/UI/regression/release gates on the final source; inspect
|
||||||
skipped tests and report actual hardware/runtime coverage.
|
skipped tests and report actual hardware/runtime coverage.
|
||||||
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a
|
- [x] Prepare compatible signed app catalog; old runtimes must not apply a
|
||||||
migration before they have backup/recovery support.
|
migration before they have backup/recovery support.
|
||||||
- [ ] Version/changelog and OTA payload prepared, validated and signed by user.
|
- [x] Version/changelog and OTA payload prepared, validated and signed by user.
|
||||||
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
|
- [x] Raw ISO built; payload hashes/content verified; full installation and
|
||||||
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
|
installed-system boot tested in QEMU/KVM without network.
|
||||||
|
- [x] User signs ISO checksums; publish OTA and ISO plus verification files on
|
||||||
git and ngit; independently read back hashes and update discovery.
|
git and ngit; independently read back hashes and update discovery.
|
||||||
- [ ] Provide LAN scp command for the new raw ISO.
|
- [x] Provide LAN scp command for the new raw ISO.
|
||||||
|
|
||||||
Latest backend source verification: 1,609 passed, zero failed, four existing
|
Latest backend source verification: 1,617 passed, zero failed, four existing
|
||||||
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
||||||
|
|
||||||
## Angor verification — 2026-09-30
|
## Angor verification — 2026-09-30
|
||||||
@@ -430,3 +436,24 @@ ignored, through the isolated runner. This includes all new port-selection cases
|
|||||||
and the existing companion security/configuration and lifecycle regressions.
|
and the existing companion security/configuration and lifecycle regressions.
|
||||||
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
|
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
|
||||||
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
|
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
|
||||||
|
|
||||||
|
|
||||||
|
## Final accepted artifacts — 1.8.22-alpha
|
||||||
|
|
||||||
|
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
|
||||||
|
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
|
||||||
|
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
|
||||||
|
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
|
||||||
|
on both boxes passed. No installed app was restarted by the safe diagnostics,
|
||||||
|
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
|
||||||
|
were preserved during final management deployment.
|
||||||
|
|
||||||
|
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
|
||||||
|
files plus the backend. Full offline installation and installed UEFI boot to the
|
||||||
|
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
|
||||||
|
paths and the installed backend have the expected hashes. No VM wallet was set up.
|
||||||
|
|
||||||
|
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
|
||||||
|
coverage and limits. Draft upload verification, offline signatures, publication
|
||||||
|
and public readback remain; the fleet still advertises 1.8.21 until those gates
|
||||||
|
finish. Do not confuse a draft asset or source push with completed publication.
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# NPM certificate failure: repair and next-release gate
|
||||||
|
|
||||||
|
Status: OPEN for release — affected Angor endpoint repaired and publicly verified;
|
||||||
|
durable source correction and release regression acceptance remain pending.
|
||||||
|
|
||||||
|
The operator requested immediate repair on Shorty's node and a complete, tested
|
||||||
|
fix for subsequent releases. The independent review agent acknowledged receipt
|
||||||
|
of the repair/handoff on 2026-10-01. This does **not** establish receipt by the
|
||||||
|
owner of another release session. That owner must acknowledge this document and
|
||||||
|
record implementation and acceptance before shipping the next OTA or ISO.
|
||||||
|
|
||||||
|
## Evidence and immediate repair
|
||||||
|
|
||||||
|
The live investigator reported NPM certificate failures at 18:06, 18:07 and
|
||||||
|
18:10 UTC on 2026-10-01: the CA received HTTP 404 for its HTTP-01 challenge.
|
||||||
|
The running container mounts `/var/lib/archipelago/nginx-proxy-manager` at
|
||||||
|
`/data`, but host nginx served the challenge from the obsolete nested
|
||||||
|
`/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge`.
|
||||||
|
NPM writes to `/data/letsencrypt-acme-challenge` inside its container. These are
|
||||||
|
different host directories. Host nginx owns public ports 80 and 443.
|
||||||
|
|
||||||
|
The investigator backed up `/etc/nginx/sites-available/archipelago` as
|
||||||
|
`/etc/nginx/sites-available/archipelago.before-angor-acme-1790878342`, corrected
|
||||||
|
the default HTTP challenge root to the actual mount's challenge directory,
|
||||||
|
passed `nginx -t`, and reloaded nginx. A temporary challenge file written inside
|
||||||
|
NPM returned its exact expected body over the public domain's port 80.
|
||||||
|
This initial probe proves the repaired challenge route; it alone does not prove issuance,
|
||||||
|
certificate attachment, public HTTPS routing, renewal, or release persistence.
|
||||||
|
No wallet or channel data is involved in this repair.
|
||||||
|
|
||||||
|
## Independently confirmed source inconsistencies
|
||||||
|
|
||||||
|
- `apps/nginx-proxy-manager/manifest.yml`: base app directory mounts at `/data`;
|
||||||
|
separate `letsencrypt` directory mounts at `/etc/letsencrypt`; only the admin
|
||||||
|
port is published. NPM's own public listeners are not published by this path.
|
||||||
|
- `image-recipe/configs/nginx-archipelago.conf`: default challenge location uses
|
||||||
|
the obsolete nested `data/letsencrypt-acme-challenge` root.
|
||||||
|
- `scripts/sync-npm-public-hosts.sh`: both SQLite DB and challenge root use the
|
||||||
|
nested directory. Missing DB exits successfully without syncing any hosts.
|
||||||
|
- `scripts/container-doctor.sh::fix_npm_public_hosts`: its independent nested
|
||||||
|
DB existence guard prevents the synchronizer from running on manifest installs.
|
||||||
|
- `core/archipelago/src/api/rpc/package/config.rs` and `runtime.rs`: legacy
|
||||||
|
creation/repair still mount the nested `data` directory at `/data`.
|
||||||
|
- `scripts/first-boot-containers.sh`: legacy first boot creates and mounts nested
|
||||||
|
data, and publishes different public-listener ports from the manifest path.
|
||||||
|
- The synchronizer exports selected NPM fields to host nginx. It checks enabled
|
||||||
|
hosts with a certificate, but does not filter deleted rows, validate inserted
|
||||||
|
configuration values, or preserve all NPM access/custom-location behavior.
|
||||||
|
It restores the old generated file on syntax failure, but not reload failure.
|
||||||
|
|
||||||
|
## Required implementation
|
||||||
|
|
||||||
|
1. Define one authoritative method for finding the active NPM data mount and
|
||||||
|
certificate store across fresh installs and supported legacy layouts. Do not
|
||||||
|
blindly change mounts and strand the operator's existing DB, hosts or account.
|
||||||
|
Detect ambiguous dual databases explicitly. Back up before any migration;
|
||||||
|
preserve existing certificates, private keys, renewal files, account records,
|
||||||
|
custom settings, permissions and uninstall decisions. Repeated migration must
|
||||||
|
be harmless. Failed migration must leave the original usable state intact.
|
||||||
|
2. Make default and named-host HTTP challenge routes use that active directory.
|
||||||
|
Include pre-certificate issuance and renewal under forced HTTPS. Do not expose
|
||||||
|
account/private-key/database directories through nginx.
|
||||||
|
3. Correct the synchronizer and doctor gate together, with reliable lifecycle
|
||||||
|
invocation after host/certificate edits and service startup. A certificate
|
||||||
|
created in NPM must actually become the certificate served by public nginx.
|
||||||
|
Avoid requiring users to run a repair command for each host or renewal.
|
||||||
|
4. Define how the host bridge preserves NPM routing and security settings.
|
||||||
|
Handle disabled/deleted hosts, host edits, certificate replacement/deletion,
|
||||||
|
multiple domains, custom locations and access restrictions correctly. Do not
|
||||||
|
silently publish a restricted NPM host as an unrestricted host-nginx proxy.
|
||||||
|
Validate DB-derived configuration, serialize concurrent writers, avoid
|
||||||
|
unnecessary reloads, and retain a working config on generation/test/reload
|
||||||
|
failure. Report actionable failure causes instead of apparent success.
|
||||||
|
5. Carry the correction through actual OTA migration and fresh ISO paths.
|
||||||
|
Include source, runtime scripts, nginx configuration, and app catalog as
|
||||||
|
applicable. Verify candidate package contents and installed behavior rather
|
||||||
|
than assuming a source edit is shipped by every packaging path.
|
||||||
|
|
||||||
|
## Acceptance matrix — all relevant gates require recorded results
|
||||||
|
|
||||||
|
Use disposable fixtures/test domains for destructive/error cases and ACME
|
||||||
|
staging for repeated issuance/renewal. Avoid production CA retry loops. Backend
|
||||||
|
unit tests on installed nodes must use `scripts/test-backend-isolated.sh` per
|
||||||
|
`AGENTS.md`. Do not reboot an operator node without the necessary recovery/access
|
||||||
|
arrangements and authorization; use a disposable VM for release lifecycle tests.
|
||||||
|
|
||||||
|
- [ ] Fresh manifest installation: actual mount, DB path, admin API, public
|
||||||
|
challenge file and first certificate request work without manual repair.
|
||||||
|
- [ ] Legacy nested-data upgrade: hosts, accounts, certs, renewal data and
|
||||||
|
custom settings remain intact; the active DB is still the original DB.
|
||||||
|
- [ ] Current flat-data upgrade: same preservation assertions; no empty database
|
||||||
|
is initialized and no old nested DB is silently chosen instead.
|
||||||
|
- [ ] Ambiguous dual DBs, missing/corrupt DB, backup failure and permission errors
|
||||||
|
fail safely with useful diagnostics; no deletion or identity replacement.
|
||||||
|
- [ ] Challenge file created in container is fetched byte-for-byte from public
|
||||||
|
port 80 for an unconfigured domain, named host and forced-HTTPS host.
|
||||||
|
- [ ] Staging first issuance completes through the normal NPM UI/API. One live
|
||||||
|
production issuance on the affected node is independently verified with
|
||||||
|
hostname, trust chain, validity and actual served certificate.
|
||||||
|
- [ ] Certificate binding and public HTTPS route reach the intended Angor
|
||||||
|
backend; normal API health and a representative read-only indexed request
|
||||||
|
are checked separately from TLS. Backend readiness failures stay explicit.
|
||||||
|
- [ ] Staging renewal succeeds through the normal scheduling/renewal path and
|
||||||
|
public nginx reloads the renewed certificate without manual intervention.
|
||||||
|
- [ ] Host create/edit/disable/delete, certificate replacement, multi-domain
|
||||||
|
routing, restrictions and custom locations match supported NPM behavior.
|
||||||
|
- [ ] Injection/invalid data, concurrent sync, nginx syntax failure and reload
|
||||||
|
failure preserve the previous working service; retries converge safely.
|
||||||
|
- [ ] NPM restart, manager restart, host nginx restart, controlled VM reboot and
|
||||||
|
repeated reconciliation preserve public routing and certificates.
|
||||||
|
- [ ] Migration is idempotent; upgrade rollback retains original data and
|
||||||
|
usable routing. Existing unrelated public hosts continue working.
|
||||||
|
- [ ] Signed OTA and RAW ISO candidate contents contain the same correction;
|
||||||
|
installed OTA legacy/flat layouts and fresh ISO pass the relevant checks.
|
||||||
|
- [ ] Release owner acknowledges receipt and records exact commit/artifact IDs,
|
||||||
|
test commands/results, live evidence, remaining limits and release decision.
|
||||||
|
|
||||||
|
## Handoff acknowledgements
|
||||||
|
|
||||||
|
- 2026-10-01: independent review agent received the parent investigator's live
|
||||||
|
fix details and explicitly acknowledged responsibility for source review and
|
||||||
|
this test/handoff checklist. No production code or node changes by reviewer.
|
||||||
|
- 2026-10-01: next-release owner explicitly acknowledged this handoff in
|
||||||
|
`/tmp/npm-release-handoff-ack.txt` and adopted the matrix as a required
|
||||||
|
1.8.23-alpha OTA/raw ISO gate. Received the operator report that Shorty
|
||||||
|
certificate npm-8 is issued and public HTTPS health returns 200. Independent
|
||||||
|
release validation and the durable fleet correction remain pending.
|
||||||
|
|
||||||
|
## Final live repair evidence — investigator report, 2026-10-01
|
||||||
|
|
||||||
|
The live investigator issued the certificate through NPM's own API using an
|
||||||
|
ephemeral in-memory local admin token, without changing passwords or disclosing
|
||||||
|
the token. Certificate ID 8 expires at `2026-12-30 17:16:35`; existing proxy host
|
||||||
|
ID 2 now uses that certificate with forced HTTPS enabled.
|
||||||
|
|
||||||
|
The running container publishes only its admin listener (container 81 to host
|
||||||
|
`127.0.0.1:8081`). The investigator therefore added a domain-scoped host-nginx
|
||||||
|
configuration at `/etc/nginx/conf.d/angor-indexer-npm.conf`. It serves HTTP 80 and
|
||||||
|
HTTPS 443 on IPv4 and IPv6, uses the corrected ACME root and NPM certificate 8,
|
||||||
|
and forwards to `http://127.0.0.1:8998`. `nginx -t` and reload passed.
|
||||||
|
|
||||||
|
External requests with normal TLS verification confirmed:
|
||||||
|
|
||||||
|
- `/health`: HTTP 200, indexed height 969474.
|
||||||
|
- `/`: valid mainnet JSON response.
|
||||||
|
- `/api/v1/fees/recommended`: valid JSON response.
|
||||||
|
- HTTP requests redirect to HTTPS with HTTP 301.
|
||||||
|
- CORS preflight `OPTIONS /api/tx` with origin `https://angor.io`: HTTP 204,
|
||||||
|
allowed origin `*`, method POST and header Content-Type. This was a preflight
|
||||||
|
check, not a transaction submission.
|
||||||
|
- An existing shop endpoint continued to return HTTPS 302.
|
||||||
|
|
||||||
|
The temporary challenge probe was removed. These are investigator-reported live
|
||||||
|
checks, not independently repeated node checks by the review agent. They establish
|
||||||
|
that the affected endpoint now serves trusted HTTPS and responds as an indexer.
|
||||||
|
They do not establish renewal, automatic host bridge updates, restart/reboot or
|
||||||
|
packaged-release correctness; the source repair remains the release owner's work.
|
||||||
|
|
||||||
|
A separate read-only public probe of the existing Shorty's website failed TLS
|
||||||
|
hostname verification. Its configuration was unchanged by this repair, and no
|
||||||
|
pre-repair baseline establishes when that mismatch began. The release owner must
|
||||||
|
investigate it separately and verify existing-host compatibility; do not attribute
|
||||||
|
it to this repair without evidence or silently mark that gate passed.
|
||||||
|
|
||||||
|
The investigator located the actual main release session, delivered the handoff,
|
||||||
|
and observed its explicit acknowledgement. The owner then recorded receipt in
|
||||||
|
`/tmp/npm-release-handoff-ack.txt` and in the acknowledgement section above.
|
||||||
|
Publication remains held for the NPM release gate. Unavailable external acceptance
|
||||||
|
must be stated explicitly and cannot be silently treated as passed.
|
||||||
|
|
||||||
|
## Urgent public dashboard exposure gate — 2026-10-01
|
||||||
|
|
||||||
|
Investigator reports the Angor relay hostname reached the default Archipelago
|
||||||
|
login because its certificate existed without a corresponding host-nginx route.
|
||||||
|
The investigator owns the immediate Shorty nginx repair; the release session
|
||||||
|
will not modify that configuration concurrently. Exact final evidence is pending.
|
||||||
|
|
||||||
|
- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot
|
||||||
|
expose the dashboard, login assets or RPC, including IPv6 and any trusted
|
||||||
|
reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly.
|
||||||
|
- [ ] LAN/private/tailnet dashboard access remains available as intended.
|
||||||
|
- [ ] Public HTTP ACME challenge access survives those restrictions.
|
||||||
|
- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing.
|
||||||
|
- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its
|
||||||
|
correct certificate; certificate existence is not route acceptance.
|
||||||
|
- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO.
|
||||||
|
|
||||||
|
## Live security containment and project discovery follow-up
|
||||||
|
|
||||||
|
2026-10-01: relay certificate existed but named public route was absent; default
|
||||||
|
HTTP/HTTPS vhosts exposed the dashboard to public clients, including direct WAN
|
||||||
|
IP access. Investigator added live `angor-relay-npm.conf` forwarding TLS cert11
|
||||||
|
to loopback8091 with WebSocket upgrade; added `00-dashboard-source-guard.conf`
|
||||||
|
private-source geo/map guard to both management default vhosts, preserving public
|
||||||
|
ACME challenge paths. Backup: `/etc/nginx/sites-available/archipelago.before-public-guard-1790879144`.
|
||||||
|
Nginx syntax validation/reload passed. External tests: public IP root and RPC
|
||||||
|
404 over HTTP and HTTPS (IP HTTPS certificate validation bypassed only for this
|
||||||
|
negative routing probe); spoofed private Host, X-Forwarded-For and X-Real-IP and
|
||||||
|
unknown Host POST RPC all404. Tailnet dashboard200; indexer health200 height969475;
|
||||||
|
relay trusted TLS NIP11 metadata200, WebSocket101, read-only Nostr REQ returned EOSE.
|
||||||
|
These are live containment results, not fleet/IPv6/reboot/security-audit completion.
|
||||||
|
Release owner acknowledged security scope in `/tmp/npm-release-handoff-ack.txt`.
|
||||||
|
|
||||||
|
User then reported no Angor projects after changing BOTH indexer and relays.
|
||||||
|
Read-only kind3030 subscription limit5: new relay returned zero events + EOSE;
|
||||||
|
`wss://relay.angor.io/` returned five events + EOSE. Advised retaining original
|
||||||
|
Angor relays alongside own relay; a newly hosted relay does not automatically
|
||||||
|
contain global project metadata. Full app project discovery acceptance remains
|
||||||
|
required. Also observed own `/api/v1/query/Angor/projects?limit=10` returns404;
|
||||||
|
reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized
|
||||||
|
route, whereas current deployment docs recommend stock Mempool. Verify actual
|
||||||
|
client version/discovery path rather than claiming fees/health prove compatibility.
|
||||||
|
|
||||||
|
## Shorty live qualification: cached-runtime guard regression — 2026-10-05
|
||||||
|
|
||||||
|
The operator signed the final NPM candidate. Release-root verification and exact
|
||||||
|
reviewed payload comparison pass; signed SHA256
|
||||||
|
`479f6193835a16dd4ab167e5c22306a878ac39b77c2e2793971e807874fbc0cb`.
|
||||||
|
This signature authorizes private qualification; it is not release publication.
|
||||||
|
|
||||||
|
Shorty baseline public shop/www/indexer/relay trusted HTTPS passes. Its prior
|
||||||
|
NPM image bytes match the pinned2.14.0 image. Consistent stopped-NPM state,
|
||||||
|
backend, unit, nginx, helpers and app metadata were backed up under
|
||||||
|
`/var/lib/archipelago/support/190-npm-20261005`. Migration reached the new private
|
||||||
|
network/listeners but failed the guard acceptance check and was rolled back.
|
||||||
|
The test initially expected the emergency guard's legacy variable; further
|
||||||
|
inspection found a real source defect, not merely that assertion mismatch.
|
||||||
|
|
||||||
|
Confirmed cause: `ensure_runtime_assets_ready` applies the management guard,
|
||||||
|
then `run_runtime_assets` installs the cached OTA's nginx template verbatim.
|
||||||
|
Shorty's cached template predates the guard. It overwrote protection before a
|
||||||
|
subsequent nginx reload; restoring the older backend repeated that path. A live
|
||||||
|
public IPv4 root probe returned200. Immediate containment applied the tested
|
||||||
|
source guard; HTTP/HTTPS root and HTTP RPC again return404. The cached legacy
|
||||||
|
runtime template is now also guarded, with its original saved privately, so
|
||||||
|
that old startup installer cannot remove protection on restart. Both emergency
|
||||||
|
and current guards are present in the active configuration. Do not claim this
|
||||||
|
attempt passed or that the broader migration is complete.
|
||||||
|
|
||||||
|
Source fix: runtime installation now renders/validates the guarded candidate
|
||||||
|
before atomic replacement under the nginx transaction lock; syntax/reload
|
||||||
|
failure restores the previous protected bytes. Rollback protects the restored
|
||||||
|
runtime template before permitting an older binary to start.11 focused tests
|
||||||
|
pass; real isolated nginx verifies the actual legacy install, old-binary copy,
|
||||||
|
invalid-template rollback, public IPv4/IPv6 denial, ACME/private access and the
|
||||||
|
existing120-case Host/SNI/forwarded-header/UI/assets/RPC/WS matrix.
|
||||||
|
The first backend suite passed1,681/0failed/4ignored before the final rollback
|
||||||
|
addition; final rerun and optimized build are required. Logs:
|
||||||
|
`/tmp/archy-190-guard-runtime-final-unit.log`,
|
||||||
|
`/tmp/archy-190-guard-runtime-final-network.log`,
|
||||||
|
`/tmp/archy-190-shorty-activation.log` (failed attempt),
|
||||||
|
`/tmp/archy-190-shorty-prepare.log`.
|
||||||
|
|
||||||
|
Only NPM's container restarted; Bitcoin, LND, ElectrumX, Angor indexer and relay
|
||||||
|
IDs/start times are unchanged. Restored shop/www/indexer/relay HTTPS returns200.
|
||||||
|
Shorty's old backend remains active under containment. Rebuild and requalify the
|
||||||
|
migration, external security and restart persistence before closing this gate.
|
||||||
|
The signed catalog contents are unchanged and need no further operator signature.
|
||||||
|
|
||||||
|
### NPM multi-domain TLS regression found by public acceptance
|
||||||
|
|
||||||
|
After the private-listener migration, local first requests passed, but public
|
||||||
|
Angor health intermittently returned502. Host nginx recorded upstream certificate
|
||||||
|
hostname mismatches when different public domains used the same NPM TLS listener.
|
||||||
|
The generated bridge inherited upstream TLS session reuse. This matches nginx's
|
||||||
|
[documented cross-SNI session-cache behaviour](https://trac.nginx.org/nginx/ticket/1340).
|
||||||
|
|
||||||
|
The bridge now explicitly sets `proxy_ssl_session_reuse off` while retaining
|
||||||
|
SNI, hostname/chain verification and the existing trusted certificates. A real
|
||||||
|
NPM fixture with two distinct certificates reproduces failure with the old
|
||||||
|
configuration on the second hostname; the fixed fixture passes40 alternating
|
||||||
|
trusted TLS requests plus ACLs, WSS, certificate replacement, restart, failed-bind
|
||||||
|
rollback and disable/delete propagation.24 Python NPM regressions pass.
|
||||||
|
`/tmp/archy-190-npm-multicert-before.log` is the expected failing reproduction;
|
||||||
|
`/tmp/archy-190-npm-multicert-integration.log` is the fixed flat-layout pass.
|
||||||
|
Nested-layout issuance/renewal qualification is running separately.
|
||||||
|
|
||||||
|
The exact helper correction is temporarily installed on Shorty and transactional
|
||||||
|
sync succeeds.40 mixed local TLS requests across four hostnames pass. Public
|
||||||
|
read-only Angor browser acceptance now passes TLS, WSS, funding/event commitment,
|
||||||
|
Explore discovery of the known fixture and full project details/statistics:
|
||||||
|
`/tmp/archy-190-shorty-migrated-angor-browser-2.log`. This remains one known fixture,
|
||||||
|
not all35-project recovery.32 external IPv4 management-denial checks and tailnet
|
||||||
|
access pass;10 HTTP/HTTPS ACME routes return the exact probe written in NPM's data
|
||||||
|
mount. Six NPM database tables and42 certificate/renewal files exactly match the
|
||||||
|
pre-migration backup (`/tmp/archy-190-shorty-state-preservation.log`).
|
||||||
|
|
||||||
|
The previously running optimized build was stopped because it predates this
|
||||||
|
embedded-helper correction. A complete new build/deployment remains mandatory.
|
||||||
|
Shorty currently has the prior A5 candidate backend plus protected runtime nginx
|
||||||
|
and this qualified helper; an A5 restart can reinstall its older helper. Do not
|
||||||
|
claim final persistence until the new binary is deployed and restart is retested.
|
||||||
|
No certificate verification was disabled for a public application or upstream.
|
||||||
|
Raw-IP/unknown-SNI negative routing probes alone bypass hostname matching.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
|||||||
|
# Work requested after 1.9.0-alpha
|
||||||
|
|
||||||
|
Status: queued by the operator on 2026-10-05. Complete the current release first;
|
||||||
|
these requests do not silently expand its artifact scope. No implementation or
|
||||||
|
acceptance is claimed by this backlog.
|
||||||
|
|
||||||
|
## 1. Distributed IndeeHub publishing and paid viewing
|
||||||
|
|
||||||
|
- Recover and reconcile the earlier design in
|
||||||
|
[the streaming plan](phase4-streaming-ecash-plan.md) and
|
||||||
|
[the distribution design](dht-distribution-design.md) against current source.
|
||||||
|
Their implementation/status statements are historical, not fresh evidence.
|
||||||
|
- Review current primary Nostr, Cashu and Bitcoin/Lightning specifications before
|
||||||
|
selecting the protocol. Distinguish interoperable standards from custom events.
|
||||||
|
- Publishing through one instance's Backstage must make the content discoverable
|
||||||
|
through other instances' **Archipelago** content source. This source is intended
|
||||||
|
to become the default eventually; do not change the default without qualification.
|
||||||
|
- Reuse supported node-to-node discovery/transports and the file-payment method
|
||||||
|
negotiation: show methods the recipient actually accepts. Include Cashu and
|
||||||
|
Lightning to the automatically provisioned ecash Lightning address from first
|
||||||
|
use; do not require a producer to operate LND to receive initial payments.
|
||||||
|
- Pay the producer's wallet, verify settlement before granting access, and make
|
||||||
|
payment retries/delivery recovery idempotent. Keep producer revenue separate
|
||||||
|
from any optional hosting/relay bandwidth charges in the older plan.
|
||||||
|
- Define timed viewing entitlements, start/expiry semantics, reconnect, resume,
|
||||||
|
seek, device/session scope and clock/error handling. Evaluate encrypted media
|
||||||
|
and authorized key delivery without claiming that delivered video or keys can
|
||||||
|
be made impossible to copy or revoked retroactively.
|
||||||
|
- Use the operator's video uploaded to **Yaya Cloud**, publishing through Backstage
|
||||||
|
for the demo. Identify the exact file and preserve the source; do not select an
|
||||||
|
unrelated personal video or publish other Cloud contents. Payment-test amounts
|
||||||
|
need explicit bounded authorization before real funds are spent.
|
||||||
|
- Deliver a coherent demo: publish on one node, discover on another, select a
|
||||||
|
supported payment method, pay producer, watch, resume without repayment, and
|
||||||
|
enforce expiry. Cover publisher outage, duplicate events, wrong mint, failed/
|
||||||
|
delayed payment, restart and lost responses. Preserve privacy and access rules.
|
||||||
|
- Treat this as the foundation for future fully featured node-sharing apps,
|
||||||
|
introduced and qualified individually.
|
||||||
|
|
||||||
|
## 2. IndeeHub native Nostr signer and companion reliability
|
||||||
|
|
||||||
|
- Reproduce intermittent native-signer login failure and companion grey screen
|
||||||
|
requiring refresh/re-login. Inspect both browser and actual Android WebView.
|
||||||
|
- Cover iframe origins, signing permissions, redirects, callback/session state,
|
||||||
|
background/resume, expired sessions, refresh and cancellation without weakening
|
||||||
|
authentication or exporting signing keys. Preserve useful errors and recovery.
|
||||||
|
- Consult existing signer bridge documentation and prior origin/reload fixes;
|
||||||
|
do not assume those earlier fixes address this fresh report.
|
||||||
|
|
||||||
|
## 3. Node peering and discovery
|
||||||
|
|
||||||
|
- Diagnose Yaya ↔ Archy dev: requests appear approved/pending but neither node
|
||||||
|
appears in the other's peers; the flow is also slow.
|
||||||
|
- Trace request, delivery, approval, identity, persistence and both-node peer-list
|
||||||
|
reconciliation. Test restart, retry/duplicates, offline recovery and reciprocal
|
||||||
|
visibility; distinguish requested, approved, connecting and connected states.
|
||||||
|
- Show Nostr requests in the appropriate discovery/request UI.
|
||||||
|
- Rename **Find Nodes** to **Connect with Nodes**, consistently with accessibility,
|
||||||
|
navigation and translation conventions.
|
||||||
|
|
||||||
|
## 4. Framework Monitoring
|
||||||
|
|
||||||
|
- Investigate the reported non-working Monitoring screen on Framework with
|
||||||
|
read-only diagnostics first. Verify actual metrics, loading/error states,
|
||||||
|
permissions and refresh/reconnect on that node. Preserve wallet/radio state.
|
||||||
|
|
||||||
|
## 5. Immich / Nextcloud files in Cloud
|
||||||
|
|
||||||
|
- Assess integration so installed Immich/Nextcloud files appear under the correct
|
||||||
|
Cloud Files categories, without duplicating storage or exposing another user's
|
||||||
|
private data. Determine supported APIs, user identity/permissions, thumbnails,
|
||||||
|
originals, virtual paths and large-library pagination/indexing.
|
||||||
|
- Define view/download/edit/delete semantics per source. Preserve application
|
||||||
|
ownership, databases, metadata and trash/versioning; do not directly mutate
|
||||||
|
application-managed storage to bypass its API.
|
||||||
|
- Test installation/removal, permissions, unavailable apps, overlapping filenames,
|
||||||
|
duplicate detection and category accuracy before enabling an integration.
|
||||||
|
|
||||||
|
## 6. Web5 header and node connection flow planning
|
||||||
|
|
||||||
|
- Inspect the top-bar **Wallet** label in Web5. The operator requests removing
|
||||||
|
this cosmetic label; preserve any actual wallet navigation or accessibility
|
||||||
|
function until its role is established, and report if it is more than a label.
|
||||||
|
- Plan a less hidden, clearer discovery and peering journey on mobile and desktop
|
||||||
|
using the existing design system, visual styles and components. This is a flow
|
||||||
|
and information-placement change, not a visual redesign.
|
||||||
|
- Include visible entry to **Connect with Nodes**, incoming/outgoing Nostr
|
||||||
|
requests, approval, pending/connecting/connected status, reciprocal peer
|
||||||
|
confirmation, offline/retry recovery, and clear return paths. Show how this
|
||||||
|
relates to existing peers and node details rather than adding duplicate flows.
|
||||||
|
- Produce reviewable mobile/desktop flow plans before broad navigation changes;
|
||||||
|
cover first connection and repeat use, touch/keyboard access, empty/error
|
||||||
|
states, and the current Yaya/dev approval bug. Keep diagnostic implementation
|
||||||
|
details out of normal user-facing steps.
|
||||||
|
|
||||||
|
## 7. Companion app launch latency
|
||||||
|
|
||||||
|
- Reproduce intermittent long app-opening delays on the actual companion and
|
||||||
|
compare desktop/mobile browser timing for the same node/app. Measure discovery,
|
||||||
|
readiness polling, authentication/signing, route/proxy connection, WebView
|
||||||
|
creation and first useful rendered content separately.
|
||||||
|
- Remove avoidable waits, duplicated checks and retry loops without launching
|
||||||
|
before an app can accept connections. Cover cold/warm launches, switching apps,
|
||||||
|
background/resume, flaky connectivity, expired authentication and app restarts.
|
||||||
|
- Keep feedback clear and immediate, with bounded cancellation/retry and preserved
|
||||||
|
navigation. Record before/after measurements and test actual Android devices.
|
||||||
|
|
||||||
|
## 8. Fleet comprehensive acceptance
|
||||||
|
|
||||||
|
- Inventory every current Fleet capability and turn it into a test matrix rather
|
||||||
|
than assuming a working overview proves all functions work.
|
||||||
|
- Cover discovery, identity/deduplication, authorization, adding/removing nodes,
|
||||||
|
status/metrics freshness, selections/filters, remote actions, update discovery
|
||||||
|
and progress/results, reconnect/offline/restart recovery and error handling.
|
||||||
|
- Include mixed software versions, slow/unreachable nodes, partial success,
|
||||||
|
duplicate/late replies, permission rejection and desktop/mobile behavior.
|
||||||
|
- Use disposable fixtures for destructive/restart/update scenarios; preserve real
|
||||||
|
node wallets, application data and user choices. No new spending is authorized.
|
||||||
|
|
||||||
|
## 9. AIUI first-use/provider/funding experience
|
||||||
|
|
||||||
|
- When AIUI opens without a usable AI connection, guide the user to setup rather
|
||||||
|
than presenting only a failed model response. Distinguish missing configuration,
|
||||||
|
invalid credentials, insufficient Routstr funds and temporary provider outage.
|
||||||
|
- Offer concise choices to add a Claude credential, configure the supported
|
||||||
|
OpenAI/Codex connection, or fund Routstr. Verify the actual provider/runtime
|
||||||
|
authentication methods before labeling a credential field or promising support.
|
||||||
|
- Chat may present action buttons; use a private credential form/modal for keys,
|
||||||
|
not an ordinary chat message. Keep credentials out of model prompts, history,
|
||||||
|
logs and screenshots; use existing secure storage and permission boundaries.
|
||||||
|
- Open Routstr funding as a coherent modal where supported, show balance/payment
|
||||||
|
state and update availability after funding. Preserve any unsent prompt and
|
||||||
|
let the user continue when setup succeeds. No surprise automatic charges.
|
||||||
|
- Match the existing design system and support small screens, keyboard access,
|
||||||
|
cancellation, invalid/expired keys, funding delay, reload/background/resume and
|
||||||
|
successful first response. Test actual companion and desktop flows.
|
||||||
|
|
||||||
|
## 10. Node availability, offline duration and network map
|
||||||
|
|
||||||
|
- Make availability easy to scan in Fleet and Connected Nodes using explicit
|
||||||
|
status text as well as existing visual indicators. Show last successful contact
|
||||||
|
and an elapsed duration, such as "Last seen 2 hours ago"; only say "Offline for"
|
||||||
|
when an observed offline transition supports that claim. Never equate an old
|
||||||
|
relay advertisement, failed monitoring query or unknown status with proof that
|
||||||
|
a node has been continuously offline.
|
||||||
|
- Place confirmed offline nodes after online nodes by default, with stable order
|
||||||
|
within each group. Specify how connecting and unknown/stale nodes are ordered;
|
||||||
|
preserve user-selected sorting, filters, selection and scroll position.
|
||||||
|
- Show offline and unknown/stale nodes distinctly on the network map, with text
|
||||||
|
or accessible detail including last contact. Do not present retained historical
|
||||||
|
links as live connections or silently drop long-offline nodes from the map.
|
||||||
|
- Retain useful last-contact history across refresh/restart; reconcile timestamps
|
||||||
|
across discovery, peering and monitoring. Handle clock skew, never-seen nodes,
|
||||||
|
stale cached data, long outages and reconnects without false precision.
|
||||||
|
- Test short/long outages, stale relay events, monitor-only failures, network
|
||||||
|
partitions, app background/resume and status recovery on desktop and companion.
|
||||||
|
Use the existing design system and avoid color-only status communication.
|
||||||
|
|
||||||
|
## 11. Web5 connection/sync speed and responsive navigation
|
||||||
|
|
||||||
|
- Profile and improve connection establishment, discovery, peer synchronization
|
||||||
|
and Web5 data loading. Measure each stage and reduce avoidable serial waits,
|
||||||
|
duplicate requests, excessive timeouts and retry storms. Preserve identity,
|
||||||
|
trust verification and accurate readiness/status reporting.
|
||||||
|
- Reproduce delayed taps/navigation for Web5 and Cloud, especially in the Android
|
||||||
|
companion, and the Find Nodes / nodes entry into **Federation & Peers**. Record
|
||||||
|
tap-to-feedback, route-render and useful-data timings separately on cold/warm
|
||||||
|
loads and with realistic slow/offline nodes and larger peer/file lists.
|
||||||
|
- Every navigation tap must give immediate feedback using existing components;
|
||||||
|
render the destination shell promptly and load independent data incrementally.
|
||||||
|
One slow node/request must not block the whole screen or tab navigation.
|
||||||
|
- Keep cached content visibly identified when stale; use bounded loading states
|
||||||
|
and useful retry/error feedback. Preserve back navigation, scroll, selection
|
||||||
|
and in-progress work; cancel or ignore superseded requests safely.
|
||||||
|
- Cover all tabs, not just these reported routes. Test rapid tab switching,
|
||||||
|
repeated taps, background/resume, expired sessions, unavailable peers, partial
|
||||||
|
responses and recovery on actual companion hardware and desktop/mobile browsers.
|
||||||
|
- Record before/after latency distributions and agree measurable budgets after
|
||||||
|
establishing a baseline. Faster feedback alone is not evidence that the
|
||||||
|
underlying connection/synchronization delay has been fixed.
|
||||||
|
|
||||||
|
## 12. Missing Fleet card metrics and secure FIPS transport
|
||||||
|
|
||||||
|
- Reproduce missing metrics on multiple Fleet **Nodes** cards; trace collection,
|
||||||
|
authorization, transport delivery, identity matching, subscriptions/cache and
|
||||||
|
rendering separately. Verify each metric is real, current and associated with
|
||||||
|
the correct node; show unavailable/stale explicitly rather than invented zeros.
|
||||||
|
- Evaluate and use existing FIPS transport wherever supported and measurably
|
||||||
|
beneficial across Fleet, discovery, peering and synchronization. Preserve peer
|
||||||
|
authentication, access controls, confidentiality and existing trust boundaries;
|
||||||
|
transport reachability must never grant permission to read metrics or act.
|
||||||
|
- Prefer fresh authenticated updates without duplicate polling or excessive
|
||||||
|
subscriptions. Measure propagation latency and resource use, including large
|
||||||
|
fleets, while retaining safe fallback for unavailable/incompatible FIPS peers.
|
||||||
|
- Test spoofed/unauthorized peers, expired trust, disconnect/reconnect, partial
|
||||||
|
metrics, stale/out-of-order/duplicate messages, mixed transport capability and
|
||||||
|
fallback recovery. Do not claim FIPS is faster until timings demonstrate it.
|
||||||
|
|
||||||
|
## Required qualification before user acceptance testing
|
||||||
|
|
||||||
|
Operator explicitly requires extensive headless testing using the authorized
|
||||||
|
nodes before deploying the next features for UAT. For each task, qualify source
|
||||||
|
regressions, actual browser behavior, integration between appropriate nodes,
|
||||||
|
permissions and failure/recovery paths first. Use existing access and useful
|
||||||
|
read-only checks on real nodes; use isolated fixtures for destructive scenarios.
|
||||||
|
Headless browser success does not substitute for actual companion/WebView checks
|
||||||
|
where lifecycle, native signing, media or navigation behavior is involved.
|
||||||
|
|
||||||
|
Record exact revisions/artifacts, nodes, environments, before/after measurements,
|
||||||
|
executed scenarios, failures and untested boundaries. Retest fixes and relevant
|
||||||
|
regressions; do not mark gaps passed or claim perfection. UAT handoff must contain
|
||||||
|
short node-specific steps with expected outcomes, deployment scope and rollback.
|
||||||
|
Preserve wallets, files, identities and operator app choices throughout.
|
||||||
|
|
||||||
|
If IndeeHub changes are needed, include its app update explicitly: build and test
|
||||||
|
the versioned image, preserve app data and configuration, qualify fresh install,
|
||||||
|
upgrade/restart and rollback, publish through the signed app catalog, and verify
|
||||||
|
existing nodes discover and apply the intended version. Distinguish an app-only
|
||||||
|
release from any backend/OTA dependency; use the documented decoupled app-update
|
||||||
|
path where supported. Do not silently require a full OTA for an app-only change.
|
||||||
|
|
||||||
|
Sequencing clarification: finish1.9.0-alpha first. Publish any required IndeeHub
|
||||||
|
app update at the end of the follow-up implementation and qualification, not
|
||||||
|
ahead of that work or as an untested addition to the current release.
|
||||||
|
|
||||||
|
## 13. V4V Portainer demo as a Yaya node app
|
||||||
|
|
||||||
|
- After the current release, deploy/package the existing V4V Portainer deployment
|
||||||
|
as a demo app on Yaya, showcasing how an ordinary third-party app works on a
|
||||||
|
node **without native Nostr signer integration**, as explicitly requested.
|
||||||
|
- Inspect the actual existing Portainer source, branch/image revision, Compose
|
||||||
|
stack, access/authentication and data before changes; retain the working V4V
|
||||||
|
site, stack and persistent state. Do not confuse this with the public Archipelago
|
||||||
|
software demo at demo.archipelago-foundation.org.
|
||||||
|
- Follow the current app-development guide and supported app packaging/gate/
|
||||||
|
lifecycle conventions. Define the app card/icon/category, launch URL/readiness,
|
||||||
|
network/auth boundaries, health, configuration and persistent mounts properly.
|
||||||
|
Do not expose the native signer or implicitly grant signing permissions.
|
||||||
|
- Qualify fresh installation in isolation, then Yaya deployment, desktop/mobile/
|
||||||
|
companion launch, normal app functionality, restart, update/rollback and safe
|
||||||
|
removal behavior. Verify the deployed app actually runs the intended V4V source
|
||||||
|
revision, not a stale build or unrelated image.
|
||||||
|
- Record a short reproducible demo flow and operator UAT checklist. Preserve
|
||||||
|
existing Gitea/Portainer connectivity and unrelated apps; no new payment or
|
||||||
|
public sharing of private test content is implied by the demo packaging.
|
||||||
|
|
||||||
|
### V4V node-only catalog and Sovereign Music promotion
|
||||||
|
|
||||||
|
- Source is on the existing Gitea on the146 server; locate the actual V4V repo,
|
||||||
|
branch and deployment revision there rather than guessing a replacement source.
|
||||||
|
- Add a **Sovereign Music** banner for V4V on Yaya, following the existing
|
||||||
|
Sovereign Streaming banner treatment and using the app's own login background.
|
||||||
|
Retrieve and inspect the real asset; do not invent a replacement illustration.
|
||||||
|
- Both demo app availability and its promotion must be confined to Yaya. Evaluate
|
||||||
|
a signed per-node/DID-scoped demo catalog or existing supported node-specific
|
||||||
|
candidate mechanism. Do not publish the demo to the global catalog or let
|
||||||
|
unrelated nodes install it implicitly through a shared catalog cache.
|
||||||
|
- Test matching/nonmatching identities, copying URLs/catalogs between nodes,
|
||||||
|
missing identity, refresh/restart/update and promotion visibility. Catalog
|
||||||
|
selection or visibility must not bypass normal artifact-signature enforcement.
|
||||||
|
- This may become a real app later; preserve an explicit tested promotion path
|
||||||
|
from node-only demo to proper public app release without duplicate app IDs,
|
||||||
|
conflicting state, lost configuration or automatic exposure before approval.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Archipelago 1.8.22-alpha acceptance
|
||||||
|
|
||||||
|
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
|
||||||
|
|
||||||
|
## Verified application and runtime changes
|
||||||
|
|
||||||
|
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
|
||||||
|
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
|
||||||
|
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
|
||||||
|
- Six companion dashboard images built using the current registry.
|
||||||
|
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
|
||||||
|
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
|
||||||
|
|
||||||
|
## Live acceptance
|
||||||
|
|
||||||
|
The exact OTA bytes were deployed to the development box and ThinkPad X250.
|
||||||
|
Native Bitcoin/LND and the X250 production site retained their container identity
|
||||||
|
and start time during these final management deployments. Both boxes completed
|
||||||
|
12-minute observations including scheduled diagnostics with running containers
|
||||||
|
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
|
||||||
|
|
||||||
|
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
|
||||||
|
visible login/admin page, readable inline Bitcoin version choices and pruning
|
||||||
|
checkbox passed. No Bitcoin installation was triggered by this test.
|
||||||
|
|
||||||
|
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
|
||||||
|
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
|
||||||
|
AIUI embedding layers. Standalone AIUI retains its wallpaper.
|
||||||
|
|
||||||
|
Portainer's actual production network namespace fetched Git refs and the Compose
|
||||||
|
file after final deployment. Original mounts were preserved. Earlier disposable
|
||||||
|
fresh/reverse-install and migration/rollback tests, and the production host's
|
||||||
|
operator-initiated reboot check, passed.
|
||||||
|
|
||||||
|
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
|
||||||
|
change, rejected-payment refund, exact file bytes, Files access and free repeat
|
||||||
|
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
|
||||||
|
closed; the open pull-request list is empty.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
|
||||||
|
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
|
||||||
|
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
|
||||||
|
subprocess test and creation of a production Minibits profile were not run.
|
||||||
|
- The previously recorded unsafe-doctor incident changed X250 container start
|
||||||
|
times before the final fix. Persistent databases were present after recovery,
|
||||||
|
but no pre-incident cryptographic wallet-identity baseline was available.
|
||||||
|
Do not describe recovery evidence as an exact pre-incident balance comparison.
|
||||||
|
- No claim of perfect behavior on every device, network or future failure is made.
|
||||||
|
|
||||||
|
## Raw ISO acceptance
|
||||||
|
|
||||||
|
The raw ISO is 2,755,072,000 bytes. SHA-256:
|
||||||
|
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
|
||||||
|
|
||||||
|
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
|
||||||
|
hooks, build contexts, current doctor overlay, crash-capture configuration,
|
||||||
|
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
|
||||||
|
files match exactly. AIUI metadata names the tested source commit.
|
||||||
|
|
||||||
|
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
|
||||||
|
64 GiB NVMe virtual disk and no network completed the full installation. This
|
||||||
|
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
|
||||||
|
bootloader and initramfs generation. Cold boot with the ISO detached reached the
|
||||||
|
visible Welcome to Archipelago setup screen. The installed backend and both
|
||||||
|
historical/current doctor paths matched source hashes. Backend/nginx were active;
|
||||||
|
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
|
||||||
|
No wallet was initialized in this disposable VM.
|
||||||
|
|
||||||
|
The first automatic VM reboot selected the still-attached installer ISO. That
|
||||||
|
was corrected in the test configuration by detaching the ISO and explicitly
|
||||||
|
booting NVMe. It was not accepted as an installed-system boot. The subsequent
|
||||||
|
cold boot above is the successful acceptance run.
|
||||||
|
|
||||||
|
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
|
||||||
|
after the ISO build and VM acceptance.
|
||||||
|
|
||||||
|
## Publication verification
|
||||||
|
|
||||||
|
All three operator signatures verify against the pinned release root. The five
|
||||||
|
Gitea assets match independent server-side SHA-256 checks. Both OTA components
|
||||||
|
also passed complete public HTTPS downloads with exact hashes and sizes; the
|
||||||
|
raw ISO passed public size/range checks and both checksum sidecars read back
|
||||||
|
exactly. Only after these checks were the signed OTA manifest and compatible
|
||||||
|
app catalog promoted. The release tag identifies the tested source above.
|
||||||
|
|
||||||
|
Git and ngit publication completed on 2026-10-01. Both repository relays
|
||||||
|
acknowledged the ngit release; independent `release view` resolved all five
|
||||||
|
assets with exact hashes and sizes and no unresolved asset IDs. Main and the
|
||||||
|
release tag were pushed to both remotes.
|
||||||
|
|
||||||
|
Public main-branch OTA manifests and the app catalog read back byte-for-byte
|
||||||
|
and verified cryptographically. Live `update.check` on the accepted dev node
|
||||||
|
reported 1.8.22-alpha with no further update, as expected for the installed
|
||||||
|
release. Discovery on an older production node was not repeated during this
|
||||||
|
publication step.
|
||||||
|
|
||||||
|
- [Release and verification files](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.22-alpha)
|
||||||
|
- [Raw ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-installer-1.8.22-alpha-unbundled-x86_64_RC1.iso)
|
||||||
|
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
# Archipelago 1.9.0 acceptance
|
||||||
|
|
||||||
|
The operator changed the release target from 1.8.23-alpha to **1.9.0**. This file retains its historical path so handoff links remain valid.
|
||||||
|
|
||||||
|
Status: PREPARING. Do not publish until artifact checks and offline signatures pass.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Durable Lightning paid-file entitlements and delivery retries, atomic buyer
|
||||||
|
ownership, compact persistent upload progress/cancellation, mobile transaction
|
||||||
|
filters, Immich inventory and retired-app uninstall, Portainer duplicate-network
|
||||||
|
migration, channel-close fee selection, and shared app-search clearing.
|
||||||
|
|
||||||
|
Angor Indexer and the optional Angor Relay remain in the signed catalog. They
|
||||||
|
were already included in 1.8.22; full-chain acceptance still awaits dev Bitcoin
|
||||||
|
initial sync. Do not advertise full-chain verification as complete.
|
||||||
|
|
||||||
|
## Validation carried into preparation
|
||||||
|
|
||||||
|
- Candidate deployed on dev and Framework with matching backend/dashboard bytes.
|
||||||
|
- Native Bitcoin/LND and Framework Immich container IDs/start times preserved.
|
||||||
|
- Six live desktop/mobile app-search cases passed.
|
||||||
|
- Actual uploads verified exact bytes, original destination after navigation,
|
||||||
|
compact progress geometry and cancellation. These are browser checks, not
|
||||||
|
physical companion acceptance.
|
||||||
|
- Framework seller's settled invoice recovered to a mode-0600 entitlement and
|
||||||
|
remained paid across another manager restart; mismatched item rejected.
|
||||||
|
- Framework CryptPad stale inventory removed with data preserved; removal
|
||||||
|
persisted after management restart. Dev normal package.uninstall RPC also
|
||||||
|
completed with preserve_data=true and zero cleanup errors for the retired ID.
|
||||||
|
- Cooperative-close fee selector tested with intercepted requests; no real
|
||||||
|
channel was closed. Real payment recovery never sent another payment.
|
||||||
|
- Shorty's Mempool inspected read-only; frontend/API healthy for over two weeks,
|
||||||
|
systemd zero restarts. Operator reported normal status after their update.
|
||||||
|
|
||||||
|
## Follow-ups accepted for release preparation
|
||||||
|
|
||||||
|
The operator authorized release preparation after available tests pass.
|
||||||
|
|
||||||
|
- Actual failed-purchase delivery still requires buyer/file confirmation. The
|
||||||
|
located invoice's source file is missing from Framework's recorded paths.
|
||||||
|
Seller settlement recovery does not prove buyer delivery.
|
||||||
|
- Physical companion upload diagnosis needs the affected device/route.
|
||||||
|
- Framework rendered inventory/uninstall checks need dashboard second-factor
|
||||||
|
authentication; SSH/runtime and dev API acceptance are recorded separately.
|
||||||
|
- Angor full-chain indexing awaits Bitcoin IBD.
|
||||||
|
- Prior Primal automatic-comment and lost-response ecash receipt follow-ups
|
||||||
|
retain their documented boundaries; this release does not claim to fix them.
|
||||||
|
|
||||||
|
## Final release checks
|
||||||
|
|
||||||
|
Pending full release gates, versioned builds, exact artifact deployment, ISO
|
||||||
|
payload/boot acceptance, pinned-root signatures and publication verification.
|
||||||
|
No universal or future-failure guarantee is implied by these tests.
|
||||||
|
|
||||||
|
## Required NPM certificate gate
|
||||||
|
|
||||||
|
The release owner acknowledged `docs/npm-certificate-handoff-20261001.md` in
|
||||||
|
`/tmp/npm-release-handoff-ack.txt`. Shorty's npm-8 issuance and HTTPS health 200
|
||||||
|
were reported by the operator; durable data-path resolution, safe host routing,
|
||||||
|
automatic certificate renewal/reload, and the handoff acceptance matrix remain
|
||||||
|
required before publication. Earlier authorization does not waive this new gate.
|
||||||
|
|
||||||
|
## Urgent public dashboard exposure gate — 2026-10-01
|
||||||
|
|
||||||
|
Investigator reports the Angor relay hostname reached the default Archipelago
|
||||||
|
login because its certificate existed without a corresponding host-nginx route.
|
||||||
|
The investigator owns the immediate Shorty nginx repair; the release session
|
||||||
|
will not modify that configuration concurrently. Exact final evidence is pending.
|
||||||
|
|
||||||
|
- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot
|
||||||
|
expose the dashboard, login assets or RPC, including IPv6 and any trusted
|
||||||
|
reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly.
|
||||||
|
- [ ] LAN/private/tailnet dashboard access remains available as intended.
|
||||||
|
- [ ] Public HTTP ACME challenge access survives those restrictions.
|
||||||
|
- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing.
|
||||||
|
- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its
|
||||||
|
correct certificate; certificate existence is not route acceptance.
|
||||||
|
- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO.
|
||||||
|
|
||||||
|
## Additional isolated security checks — not deployed
|
||||||
|
|
||||||
|
The management source-guard prototype passed five unit checks including legacy
|
||||||
|
address-specific HTTPS, idempotence, backup permissions and syntax/reload rollback.
|
||||||
|
An actual nginx instance in a private network namespace passed 120 negative
|
||||||
|
HTTP/TLS cases across IPv4/IPv6, raw/unknown/spoofed Host/SNI and forwarded headers,
|
||||||
|
including POST RPC and WebSocket upgrade requests. Exact ACME token reads,
|
||||||
|
private LAN/tailnet/ULA access, named public HTTP app routing and reload passed.
|
||||||
|
These are scoped checks, not complete fleet, trusted-tunnel, reboot or artifact
|
||||||
|
acceptance. Shorty's containment was not modified.
|
||||||
|
|
||||||
|
The NPM storage/routing prototype passed eight focused tests: fresh/flat/nested/
|
||||||
|
custom mount selection without mutation, ambiguity/wrong-mount refusal, corrupt
|
||||||
|
or uninitialized database preservation, duplicate/missing mounts, deleted/disabled
|
||||||
|
host exclusion, domain injection rejection, and rejection of mixed public and
|
||||||
|
loopback listener bindings. Automatic application/migration and end-to-end NPM
|
||||||
|
security/routing remain unfinished and block release.
|
||||||
|
|
||||||
|
Final Angor handoff was read and acknowledged in
|
||||||
|
`/tmp/angor-final-handoff-ack.txt`; see `angor-client-acceptance-20261001.md`.
|
||||||
|
One complete project flow is investigator-verified; 34 original announcements
|
||||||
|
remain unrecovered from queried sources. Full recovery acceptance remains open.
|
||||||
|
|
||||||
|
## Additional Angor public explorer gate
|
||||||
|
|
||||||
|
- [ ] Public indexer hostname serves Mempool UI and its assets/deep links/live
|
||||||
|
WebSocket updates while preserving Angor API/CORS/broadcast/readiness.
|
||||||
|
- [ ] Existing stack reused; no duplicate Mempool app/database and no management
|
||||||
|
or Bitcoin RPC exposure.
|
||||||
|
- [ ] Documentation/catalog/runtime metadata and exact OTA/ISO reflect the tested
|
||||||
|
implementation; repeat official-client browser acceptance afterward.
|
||||||
|
|
||||||
|
Confirmed official deployment guide describes a shared frontend/API origin.
|
||||||
|
Current adapter 1.0.1 is API-only; this requirement is not yet implemented.
|
||||||
|
|
||||||
|
## NPM real-image integration progress
|
||||||
|
|
||||||
|
Disposable real NPM API tests passed for both flat and legacy nested `/data`
|
||||||
|
layouts: initial account/host creation, multiple domains, custom location,
|
||||||
|
forwarded-client spoof rejection, exact challenge file access under forced HTTPS,
|
||||||
|
trusted TLS and WSS upgrade/frame, certificate replacement/reload, password and
|
||||||
|
network access lists, disable/enable/delete propagation, and restart with the
|
||||||
|
original database and account authentication preserved. Local fixture certificates
|
||||||
|
are not public Let's Encrypt staging issuance/renewal evidence; that gate is open.
|
||||||
|
|
||||||
|
Certificate replacement initially failed because the updated bridge could not
|
||||||
|
complete the upstream TLS request after replacing the fixture certificate.
|
||||||
|
Reloading and validating NPM's own TLS listener on certificate fingerprint changes,
|
||||||
|
as well as host nginx, resolved the test. Rollback/retry unit coverage was added.
|
||||||
|
|
||||||
|
The initial dev guard deployment changed only the inactive sites-available copy;
|
||||||
|
private HTTP 200 and unchanged entry bytes were insufficient acceptance evidence.
|
||||||
|
A later public-ingress-marker probe caught this: it incorrectly returned 200.
|
||||||
|
The resolver now chooses the active sites-enabled copy or resolves its symlink
|
||||||
|
without replacing the link. Guard backups live outside nginx include directories.
|
||||||
|
After the correction, live private requests return200 and marked requests 404.
|
||||||
|
No app was restarted. Direct-backend protection still awaits its candidate build.
|
||||||
|
|
||||||
|
Angor candidate UI was exercised against the actual dev Mempool stack in a
|
||||||
|
throwaway gateway: desktop/mobile rendered, zero failed JS/CSS assets, one
|
||||||
|
WebSocket connection each. The gateway was removed afterward; this is candidate
|
||||||
|
integration evidence, not a published or permanently installed app update.
|
||||||
|
|
||||||
|
### Same-node NPM networking correction
|
||||||
|
|
||||||
|
Read-only inspection of the production NPM namespace reproduced HTTP 502 for its
|
||||||
|
own configured indexer route. Host requests to the LAN upstream returned 200;
|
||||||
|
requests from the existing pasta namespace to that same LAN address were refused.
|
||||||
|
A disposable container on the proposed `slirp4netns:allow_host_loopback=true`
|
||||||
|
network returned 200 for both the LAN upstream and `host.containers.internal`.
|
||||||
|
No production NPM/nginx configuration or container was changed in this check.
|
||||||
|
|
||||||
|
The candidate now declares this network in the manifest and first-boot path,
|
||||||
|
with explicit Quadlet/API support and legacy drift detection. The Podman API
|
||||||
|
`network_options` shape was checked against `podman generate spec` locally.
|
||||||
|
The real NPM integration fixture now uses the proposed network and a LAN-bound
|
||||||
|
same-node upstream, rather than placing both fixtures on one custom bridge.
|
||||||
|
Flat-layout integration passed namespace reachability, host routing, verified
|
||||||
|
TLS/WSS, ACME file access, certificate replacement/reload, custom routes, password
|
||||||
|
and IP ACLs, spoof rejection, host lifecycle and NPM restart with preserved DB.
|
||||||
|
The earlier loopback-only fixture failed because this machine resolves the host
|
||||||
|
alias to its LAN address; its bind was corrected before repeating the test.
|
||||||
|
|
||||||
|
The latest isolated nginx guard test passed 120 public IPv4/IPv6 negative cases
|
||||||
|
plus private access, ACME, proxy-marker rejection and reload. Python guard/bridge
|
||||||
|
regressions passed 23 tests, including exact emergency-route retirement, failed
|
||||||
|
migration rollback and preserving operator-modified routes. Backend compilation
|
||||||
|
and new direct-listener tests are still pending. Required public staging renewal,
|
||||||
|
actual upgrade/reboot, yaya and exact OTA/ISO acceptance remain open.
|
||||||
|
|
||||||
|
|
||||||
|
### Active nginx site layout regression
|
||||||
|
|
||||||
|
The dev node has a regular `sites-enabled/archipelago` file, not a symlink to
|
||||||
|
`sites-available`. Both the guard and ACME resolver now select the active file.
|
||||||
|
Unit coverage verifies copied sites and symlink targets, preserving inactive
|
||||||
|
operator copies and the links themselves. Nginx configuration backups must not
|
||||||
|
be created inside `sites-enabled`, whose wildcard include would load them.
|
||||||
|
The active guard was applied on dev, with private HTTP 200 and public-ingress
|
||||||
|
marker 404 verified after reload. Shorty remains untouched. Do not count the
|
||||||
|
initial inactive-file edit as a security deployment pass.
|
||||||
|
|
||||||
|
### LoRa flasher added to release gates
|
||||||
|
|
||||||
|
Both dev and Framework lack the esptool executable and Python module. The
|
||||||
|
backend invokes a bare `esptool` and retries even process-spawn failures. The
|
||||||
|
shell updater installs it opportunistically, but the OTA runtime tool list
|
||||||
|
omits it. Candidate packaging adds a pinned self-contained `archy-esptool`
|
||||||
|
with its ESP32-S3 stub to mandatory OTA/ISO payloads. Candidate preflight runs
|
||||||
|
before stopping the radio; retries are limited to recognized serial transport
|
||||||
|
failures. Concurrent flash registration now uses one exclusive lock.
|
||||||
|
|
||||||
|
CP2102 USB identity does not uniquely identify a Heltec V3. The candidate removes
|
||||||
|
that unsafe inference from backend and UI and requires explicit board selection.
|
||||||
|
Actual board models were requested before firmware writes. Dev exposes one
|
||||||
|
CP2102 serial radio. Framework SSH works but currently exposes no mesh-radio,
|
||||||
|
ttyUSB or ttyACM port. No radio has been erased or flashed in this investigation.
|
||||||
|
Physical MeshCore UK acceptance on both nodes remains required and pending.
|
||||||
|
|
||||||
|
Dev connection diagnosis: the failed flash stopped its listener before spawn
|
||||||
|
failed and left the enabled setting true. A read-only protocol probe identifies
|
||||||
|
the existing radio as Reticulum/RNode. An explicit listener disable/enable restored
|
||||||
|
`device_connected: true` on `/dev/mesh-radio`, without flashing or native-service
|
||||||
|
restarts. Candidate configure logic now compares desired enabled state with the
|
||||||
|
actual listener task, including stopped/finished handles; same-settings reconnect
|
||||||
|
is covered by a new isolated regression. Probe/configure and flash registration
|
||||||
|
are coordinated to prevent concurrent serial owners.
|
||||||
|
|
||||||
|
The packaged `archy-esptool` build passes in a clean environment, including loading
|
||||||
|
the actual ESP32-S3 stub through esptool's own loader. It is installed and self-tested
|
||||||
|
on dev and Framework; a compatibility command supports their current backends.
|
||||||
|
This verifies tooling availability, not physical flashing. Framework's USB sysfs
|
||||||
|
inventory shows its hub/storage/network/keyboard/display devices but no serial
|
||||||
|
radio. Board confirmation and Framework radio detection remain pending.
|
||||||
|
|
||||||
|
Additional Podman API acceptance: a disposable API service created a container
|
||||||
|
with the candidate `netns`/`network_options` payload. Its effective generated
|
||||||
|
specification preserves `allow_host_loopback=true`. The normal inspect network
|
||||||
|
mode omits options for API-created containers, unlike the CLI-created case;
|
||||||
|
candidate drift detection now consults the effective specification before
|
||||||
|
recreating such a container. Added a regression against repeated recreation.
|
||||||
|
The probe container and temporary API service were removed. The real isolated
|
||||||
|
nftables tunnel regression also passed (NPM peer port and LND remain separate).
|
||||||
|
|
||||||
|
### Latest acceptance checkpoint: radio connection and release status
|
||||||
|
|
||||||
|
The complete frontend suite passed: 143 files, 1,159 tests. Type checking and
|
||||||
|
both new radio setup tests also passed. The final isolated backend build/test
|
||||||
|
run is still pending; the previous run exposed an NPM/Router port collision,
|
||||||
|
which was corrected by assigning NPM's local HTTP listener port 8088. Do not
|
||||||
|
report the previous run as fully passing or the final run as completed.
|
||||||
|
|
||||||
|
Yaya's identity has been confirmed. Its existing managed web-tunnel drop-in
|
||||||
|
clears manifest port publications and supplies private tunnel HTTP/HTTPS ports
|
||||||
|
plus the local admin port. This would suppress the candidate bridge's new
|
||||||
|
loopback HTTP/TLS listeners. Migration must preserve the working tunnel/site,
|
||||||
|
add the required local listeners, validate the managed firewall/lifecycle,
|
||||||
|
retain custom overrides, and cover repeat upgrade and rollback. The current
|
||||||
|
bridge rejects non-loopback listeners, so the supported tunnel topology also
|
||||||
|
needs explicit qualification. No tunnel or NPM runtime change was applied to
|
||||||
|
yaya during this diagnosis. Its management source guard was applied and tested:
|
||||||
|
private dashboard HTTP 200, public-ingress-marked request 404.
|
||||||
|
|
||||||
|
Dev radio connection has been restored on its existing Reticulum firmware.
|
||||||
|
Framework still does not enumerate a USB serial radio. Both nodes now have the
|
||||||
|
self-tested packaged flasher, but physical MeshCore UK flashing, post-flash
|
||||||
|
handshake and reconnect acceptance remain open pending board identification and
|
||||||
|
Framework USB detection. No device firmware has been written.
|
||||||
|
|
||||||
|
OTA, app catalog and raw ISO publication remain held. Outstanding acceptance
|
||||||
|
includes NPM migration/renewal/reboot and exact artifacts, end-to-end delivery
|
||||||
|
of the reported paid file, physical companion uploads, full Angor discovery
|
||||||
|
(the 34 missing original announcements), radio hardware tests, and the final
|
||||||
|
dev/yaya candidate deployment checks. Retained tasks above remain in scope.
|
||||||
|
|
||||||
|
### Dev Heltec V3 physical flashing result
|
||||||
|
|
||||||
|
Full 8 MiB pre-flash backup saved privately with mode 0600 and checksum. After
|
||||||
|
removing the confirmed stale radio sidecar, the exclusive read completed.
|
||||||
|
The normal mesh.flash-device RPC then flashed the official Heltec V3 Companion
|
||||||
|
USB v1.17.1-d929643 merged image successfully (100%, no error). The image's
|
||||||
|
size and SHA-256 were checked against the official GitHub release metadata.
|
||||||
|
|
||||||
|
Independent serial protocol queries confirmed model Heltec V3, firmware
|
||||||
|
v1.17.1-d929643 and actual RF readback: 869618 kHz, 62500 Hz bandwidth, SF8,
|
||||||
|
CR8 (EU/UK Narrow). This is device readback, not merely saved host settings.
|
||||||
|
The listener was then re-enabled and reported connected as meshcore. No wallet
|
||||||
|
or native Bitcoin/LND service restart was used. MeshCore remote reboot is not
|
||||||
|
supported by the current API; that attempted check returned an explicit error.
|
||||||
|
Physical unplug/replug and communication to Framework remain pending.
|
||||||
|
|
||||||
|
Live qualification additionally found incorrect binary DEVICE_INFO/SELF_INFO
|
||||||
|
parsing and a stale host-side RF-applied marker after full-chip flashing.
|
||||||
|
Candidate changes decode the current official binary layout, query the actual
|
||||||
|
firmware version during initialization, and invalidate the RF marker after a
|
||||||
|
successful flash. The dev marker was backed up and cleared before provisioning;
|
||||||
|
the independent readback above confirms settings applied. New parser, startup
|
||||||
|
cancellation and marker lifecycle regressions are queued/running; the prior
|
||||||
|
1,647 passing tests do not cover these later changes.
|
||||||
|
|
||||||
|
Framework's V4 official USB image has been downloaded and verified. Despite the
|
||||||
|
operator confirming it is plugged in, repeated sysfs/device checks show no
|
||||||
|
ESP32 USB or serial port. USER/BOOT plus RST bootloader entry was requested;
|
||||||
|
Framework has NOT been flashed and the two-device acceptance remains open.
|
||||||
|
|
||||||
|
### Operator deferral and latest qualification
|
||||||
|
|
||||||
|
Operator explicitly deferred Framework radio/hardware work and instructed us to
|
||||||
|
continue all other release tasks. Framework V4 flashing, USB reconnect and
|
||||||
|
radio-to-radio acceptance remain UNVERIFIED / OPERATOR-DEFERRED; this is not a
|
||||||
|
pass. Do not request further Framework radio operations unless needed and the
|
||||||
|
operator resumes that work. Dev V3 acceptance and durable fleet fixes remain.
|
||||||
|
|
||||||
|
The final radio backend suite passed 1,650 tests, zero failed, four ignored,
|
||||||
|
including sidecar-startup cancellation, current MeshCore metadata parsing, and
|
||||||
|
post-flash RF-marker invalidation. Frontend baseline remains 1,159 passed.
|
||||||
|
|
||||||
|
Correction to the earlier yaya tunnel concern: direct inspection of the active
|
||||||
|
Quadlet and all drop-ins confirms web-tunnel.conf ADDS private tunnel ports; it
|
||||||
|
does not contain an empty PublishPort reset. The earlier statement that it
|
||||||
|
cleared manifest listeners was incorrect. The new loopback publications therefore
|
||||||
|
coexist declaratively without editing that working tunnel drop-in. The bridge
|
||||||
|
validator now permits only the known HTTP/TLS tunnel ports bound to a currently
|
||||||
|
assigned RFC1918 address on an actual WireGuard interface named wg-web; it still
|
||||||
|
requires a separate loopback upstream, and rejects wildcard/public/unassigned
|
||||||
|
bindings and any admin-port exception. Python bridge/guard tests: 27 passed.
|
||||||
|
Actual yaya candidate upgrade and public route acceptance remain pending.
|
||||||
|
|
||||||
|
### NPM public certificate and restart qualification checkpoint
|
||||||
|
|
||||||
|
- Main backend: 1,651 passed, zero failed, four explicitly ignored hardware /
|
||||||
|
external integration tests. Container runtime library: 80 passed, zero failed.
|
||||||
|
- Bridge/management guard Python suite: 27 passed. Shell syntax and actual ISO
|
||||||
|
overlay-content test passed.
|
||||||
|
- Candidate validator inspected yaya's real runtime/WireGuard interface and
|
||||||
|
accepted its existing web tunnel publications while selecting proposed
|
||||||
|
loopback HTTP/TLS upstreams. This was read-only, not a runtime upgrade.
|
||||||
|
- Existing yaya public HTTP ACME route returned the exact random token body
|
||||||
|
written inside NPM. Lets Encrypt STAGING initial issuance and renewal dry run
|
||||||
|
succeeded using separate temporary account/config/work/log storage. Current
|
||||||
|
production certificate and host records were not replaced. Public site HTTP
|
||||||
|
and trusted HTTPS retain their authentication requirement (401).
|
||||||
|
- First renewal harness timed out while Certbot used a 292.7-second randomized
|
||||||
|
delay; the remote log confirmed successful simulated renewal. A deterministic
|
||||||
|
rerun with --no-random-sleep-on-renew returned exit 0 and success confirmation.
|
||||||
|
Only the temporary staging directory was removed afterward.
|
||||||
|
- Real disposable NPM nested-layout test completed: namespace LAN upstream,
|
||||||
|
API host creation, custom locations, client-IP spoof rejection, exact ACME
|
||||||
|
token, trusted TLS/WSS, certificate replacement, password/network ACLs,
|
||||||
|
enable/disable/delete, and restart with retained DB. Latest restart took 7.6s.
|
||||||
|
Earlier rerun exceeded the fixture's 90-second restart window; the test now
|
||||||
|
uses the manifest's 180-second budget and records both route/admin statuses
|
||||||
|
and container state on failure. Do not erase that earlier observed failure.
|
||||||
|
- Cleanup was hardened to continue cleaning other fixtures after a timeout.
|
||||||
|
Two early test runs passed functional assertions but failed cleanup; their
|
||||||
|
leftover disposable containers/networks were explicitly removed. The latest
|
||||||
|
full run exited successfully.
|
||||||
|
|
||||||
|
Legacy non-Quadlet repair now retains the old container for rollback, restores
|
||||||
|
it after replacement failure, preserves its exact image and environment values,
|
||||||
|
and waits for HTTP API readiness. Environment values use an exclusive mode0600
|
||||||
|
file cleaned on drop, not argv or the host process environment. Invalid/multiline
|
||||||
|
entries fail before stopping the original. An occupied rollback slot is preserved
|
||||||
|
for review rather than deleting an unknown container. Live interrupted-migration,
|
||||||
|
additional operator-override and rollback acceptance remain OPEN; unit success
|
||||||
|
is not proof of those deployment paths.
|
||||||
|
|
||||||
|
The deployment backend and frontend build are in progress. Full candidate dev/
|
||||||
|
yaya upgrade acceptance, reboot, exact signed OTA/catalog and booted raw ISO
|
||||||
|
remain open. Framework radio is operator-deferred, not passed. The original paid
|
||||||
|
file bytes, physical companion upload and 34 missing Angor announcements remain
|
||||||
|
separately tracked.
|
||||||
|
|
||||||
|
### Further completed acceptance
|
||||||
|
|
||||||
|
The complete disposable NPM test now includes a deliberately failed replacement
|
||||||
|
with an occupied host port. Restoring the retained container preserved its exact
|
||||||
|
container ID, database, configured hosts and authenticated API access; the test
|
||||||
|
exited successfully and cleaned its fixtures. This verifies the Podman rollback
|
||||||
|
mechanism, not yet the full installed backend's legacy-repair entry point.
|
||||||
|
|
||||||
|
Dev frontend build completed and was deployed with a separate rollback backup.
|
||||||
|
Served production Transactions layout passed at widths 390 and 1440: transparent
|
||||||
|
background, no image/blur/shadow/border, nowrap and exactly one row. Mobile rail
|
||||||
|
is 324px wide with 419px scroll content. Backend candidate compilation is still
|
||||||
|
in progress, so the latest backend changes are not yet deployed.
|
||||||
|
|
||||||
|
Dev Bitcoin remains unpruned and in IBD (observed block543676/header969495,
|
||||||
|
verification progress0.2284). This is not full-chain Angor acceptance. The operator
|
||||||
|
identified the seller of the failed Lightning purchase as Amish Paradise.
|
||||||
|
On 2026-10-02 the operator confirmed the other tester received the file and
|
||||||
|
accepted closure of this individual recovery. Seller access is no longer needed
|
||||||
|
for that recovery. This does not establish that the candidate fix delivered it;
|
||||||
|
durable settlement/delivery regression acceptance remains required before
|
||||||
|
release. No additional payment was made. Earlier references in this document
|
||||||
|
to missing original purchase bytes are superseded by this operator acceptance.
|
||||||
|
|
||||||
|
### Read-only Shorty migration preflight: remaining ownership conflict
|
||||||
|
|
||||||
|
Preflight found both flat and nested NPM databases. The live container's explicit
|
||||||
|
/data mount identifies the active one, so the candidate resolver now uses that
|
||||||
|
verified mount (or its saved validated receipt after a managed stop), preserves
|
||||||
|
both databases, and still refuses multiple databases without an authoritative
|
||||||
|
selection. Python coverage verifies both explicit choices and unchanged bytes.
|
||||||
|
This helper update occurred after the deployment binary build started; a final
|
||||||
|
release rebuild must include it. Do not claim the in-progress binary contains it.
|
||||||
|
|
||||||
|
After resolving storage, the two Angor emergency routes match the exact known
|
||||||
|
handoff templates. Another existing file, shop-btcpay.conf, conflicts with an
|
||||||
|
enabled NPM record: the manual route supplies HTTPS using certificate10, while
|
||||||
|
the NPM host currently has certificate_id0 and SSL forcing disabled. The manual
|
||||||
|
route and NPM record cover the same two public names and backend web port. Blindly
|
||||||
|
retiring the route would break its HTTPS. No database, host, certificate, route
|
||||||
|
or runtime was changed on Shorty. The bridge correctly refuses this ownership
|
||||||
|
conflict and now names its configuration file in the diagnostic. Align TLS/route
|
||||||
|
ownership and verify the public shop before retiring that manual configuration;
|
||||||
|
Shorty's full migration acceptance remains OPEN. Preserve live containment.
|
||||||
|
|
||||||
|
### Candidate deployment and additional real-upgrade ACME regression
|
||||||
|
|
||||||
|
The operator explicitly deferred Framework's physical radio investigation and
|
||||||
|
requested continuation of all other work. Framework radio remains unverified.
|
||||||
|
Dev and yaya now run the backed-up unpublished backend candidate SHA256
|
||||||
|
4c47269b3480ca0362df18dae160c073a19ea33507e04cacdad5b96837990ca6 and production
|
||||||
|
frontend index 3099c4ba44528a4a4c524f9a26159414558efa16abdd12cc7bfd64376cbb6089.
|
||||||
|
Both management health checks passed; native Bitcoin/LND container identities
|
||||||
|
and start times were unchanged. Yaya public site retains trusted TLS and its
|
||||||
|
401 authentication requirement; NPM admin API200, private dashboard200 and
|
||||||
|
public-ingress-marked dashboard404. The first yaya staging attempt stopped
|
||||||
|
before binary replacement because rsync was absent; deployment now uses Python
|
||||||
|
copying without that dependency and completed successfully.
|
||||||
|
|
||||||
|
Actual startup exposed an additional regression: the canonical nginx template
|
||||||
|
had only HTTP ACME, while the bridge demanded two locations. Startup rewrote the
|
||||||
|
previously repaired config and the bridge rejected it before fixing the nested
|
||||||
|
root. Source now adds HTTPS ACME to the shipped template and migrates the exact
|
||||||
|
recognized legacy default-server layout; custom/ambiguous layouts still fail
|
||||||
|
closed. The missing-token route must return404 rather than the dashboard SPA.
|
||||||
|
28 Python checks passed. The real isolated nginx suite now starts from the
|
||||||
|
legacy missing-HTTPS layout, applies the migration, and passes all120 public
|
||||||
|
negative cases plus HTTP/TLS exact-token, private-access and reload checks.
|
||||||
|
|
||||||
|
Applied the latest helper and its atomic ACME-only repair to yaya: actual token
|
||||||
|
written inside NPM returned exact200 over host HTTP, host HTTPS and public HTTP;
|
||||||
|
missing tokens returned404 for allthree. Public site trustedTLS/auth preserved.
|
||||||
|
Local self-signed host HTTPS was tested with certificate verification disabled;
|
||||||
|
public site HTTPS used normal certificate verification. Shorty was not modified.
|
||||||
|
The running backend still embeds the older helper/template; final rebuild is
|
||||||
|
REQUIRED before restart/reboot/persistent upgrade acceptance can pass.
|
||||||
|
|
||||||
|
The prepared unsigned catalog validates with zero metadata drift and trusted
|
||||||
|
registry hosts. Its only changed entries are NPM and Angor indexer1.0.2. It has
|
||||||
|
not been signed, installed or published. Yaya's current signed catalog retains
|
||||||
|
NPM's old pasta network/tunnel-only HTTP+TLS listeners; full NPM runtime/bridge
|
||||||
|
migration acceptance awaits the reviewed signed catalog. Do not mistake the
|
||||||
|
backend/UI deployment or ACME-only fix for completed catalog migration.
|
||||||
|
|
||||||
|
### 2026-10-02: rebuilt candidate deployed; private catalog qualification prepared
|
||||||
|
|
||||||
|
Release-profile candidate build completed successfully. SHA256:
|
||||||
|
af0648ad4ef8b6183d3c1ff485721fa40b12bb730c6e0322bc5f209ed06fce39.
|
||||||
|
Focused bootstrap tests:10 passed. Full isolated backend rerun:1651 passed,
|
||||||
|
zero failed, four explicit hardware/external ignores. Python guard/bridge28
|
||||||
|
passed again. No new source changes occurred between these checks and deployment.
|
||||||
|
|
||||||
|
Deployed this rebuilt backend on dev and yaya, with private previous-binary,
|
||||||
|
nginx and native-container baselines. Both manager health checks passed. A later
|
||||||
|
post-startup comparison confirmed Bitcoin/LND identities/start times unchanged.
|
||||||
|
The installed bridge helper now matches latest source bytes after restart.
|
||||||
|
Private UI200, marked-public HTTP/HTTPS404 and missing HTTPS challenge404 passed.
|
||||||
|
Dev HTTPS intentionally binds its LAN/WireGuard addresses, not127.0.0.1; an
|
||||||
|
initial loopback probe got connection refused, corrected to the actual listener.
|
||||||
|
This was a test-address error, not a product outage. Local self-signed HTTPS
|
||||||
|
checks skip certificate verification; public-site TLS checks use normal trust.
|
||||||
|
Full-machine reboot qualification is still pending.
|
||||||
|
|
||||||
|
Prepared a fresh candidate catalog with only NPM and Angor indexer entries changed.
|
||||||
|
Metadata drift0; registry trust check passed. Unsigned SHA256:
|
||||||
|
5801309bf21d3f6fd03ce5702d68b383a518f734092bf265f5e0ad243095a25e.
|
||||||
|
NPM's new manifest is capability-gated by runtime-migration-backup-v1; older
|
||||||
|
nodes retain the original manifest. This candidate is for private qualification,
|
||||||
|
not fleet publication. An operator-only hidden-input signer validates the exact
|
||||||
|
catalog and binary hashes, checks the pinned release root and restores the
|
||||||
|
unsigned original on failure. Its noninteractive refusal was tested. Signature
|
||||||
|
is required before testing through the nodes' normal trusted-catalog path.
|
||||||
|
No catalog, app image, OTA or ISO was published. Framework remains deferred;
|
||||||
|
all other open requirements retain their previous status.
|
||||||
|
|
||||||
|
### Signed catalog and private qualification selector
|
||||||
|
|
||||||
|
The operator signed the qualification catalog. Cryptographic release-root
|
||||||
|
verification passed locally and on yaya; after removing signature envelope fields,
|
||||||
|
its contents exactly match the reviewed unsigned candidate. No publication.
|
||||||
|
The catalog is staged under /var/lib/archipelago/qualification on both test nodes,
|
||||||
|
with previous catalog/app metadata and container identities privately backed up.
|
||||||
|
|
||||||
|
Normal mirror loading deliberately forces the public origin first, so simply
|
||||||
|
prepending a private mirror cannot reliably test an unpublished candidate.
|
||||||
|
Implemented ARCHY_APP_CATALOG_CANDIDATE as an explicit absolute-file selection:
|
||||||
|
requires an anchored release-root signature, validates before cache replacement,
|
||||||
|
retains exact signed bytes, does not alter mirrors/trust, and fails without
|
||||||
|
public fallback when the selected file is invalid. Added unsigned, tampered,
|
||||||
|
wrong-key, malformed, missing, oversized, relative-path, valid and idempotent
|
||||||
|
coverage. Full isolated backend suite:1653 passed,0 failed,4 explicit ignores.
|
||||||
|
The optimized selector build is still in progress; selection is not enabled yet.
|
||||||
|
See docs/candidate-catalog-qualification.md for activation and mandatory removal
|
||||||
|
once the tested public catalog is available. Do not leave test nodes pinned.
|
||||||
|
|
||||||
|
Yaya NPM preflight:8088/8444 are free, active public host has no conflicting
|
||||||
|
host-nginx ownership, database tables and certificate-file hashes saved privately.
|
||||||
|
No Shorty mutation. Dev public Angor reference acceptance passed TLS/WSS, exact
|
||||||
|
funding commitment, official Explore and detail/statistics again; this still
|
||||||
|
checks only the known original project, not all35. Dev Bitcoin continues syncing
|
||||||
|
(reported sync_progress approximately0.307); full-chain acceptance remains open.
|
||||||
|
Current tested hardware product codes:dev20CLS7S900 and yaya20CLS6BH00, both Podman
|
||||||
|
5.4.2; kernels6.12.74+deb13+1-amd64 and6.12.107+deb13-amd64 respectively. Framework
|
||||||
|
remains deferred. No Docker or new ISO-boot acceptance is implied.
|
||||||
|
|
||||||
|
|
||||||
|
### Signed qualification deployment and legacy upstream compatibility
|
||||||
|
|
||||||
|
The optimized candidate selector build completed, SHA256
|
||||||
|
`9cc9271ee1b4f8f13d798193cef97c1e4304a89a240f11f851eb71568bd105e1`.
|
||||||
|
Both development acceptance nodes now run it with the exact root-verified private
|
||||||
|
catalog. The isolated backend suite passed 1,653 tests, zero failures, four
|
||||||
|
explicit ignores. This is unpublished qualification, not a release.
|
||||||
|
|
||||||
|
Actual NPM migration exposed an additional regression that the LAN-upstream
|
||||||
|
fixture missed: a saved site uses pasta's former host gateway `169.254.1.2`.
|
||||||
|
Default slirp's gateway differs, so the request timed out from inside NPM even
|
||||||
|
though admin readiness passed. A disposable container verified the same saved
|
||||||
|
upstream with `slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24`.
|
||||||
|
A temporary qualification Quadlet drop-in now selects that network, using an
|
||||||
|
empty `Network=` reset before the replacement to avoid multiple network modes.
|
||||||
|
The existing site's trusted public HTTPS authentication response is restored.
|
||||||
|
|
||||||
|
Post-repair checks passed: request from NPM's actual namespace; exact saved user,
|
||||||
|
host, certificate, ACL and settings rows; unchanged certificate bytes; private
|
||||||
|
migration backup and prior unit; unchanged unrelated container IDs/start times;
|
||||||
|
retained WireGuard tunnel ports; host bridge completion; private dashboard200,
|
||||||
|
marked public HTTP/HTTPS404; missing challenge404; exact challenge body from
|
||||||
|
inside NPM over local HTTP/HTTPS and public HTTP. Native Bitcoin/LND identities
|
||||||
|
and start times remain unchanged.
|
||||||
|
|
||||||
|
**Release blocker:** integrate and test legacy gateway compatibility in all
|
||||||
|
supported runtime paths and signed manifest, including fresh/upgrade/restart
|
||||||
|
cases and LAN/host.containers.internal upstreams. The current signed candidate
|
||||||
|
alone is insufficient. Temporary user-unit drop-in
|
||||||
|
`nginx-proxy-manager.container.d/90-qualification-host-gateway.conf` must be
|
||||||
|
removed after the corrected managed configuration is verified. Do not remove
|
||||||
|
it before then or claim the migration passed without it. Candidate catalog
|
||||||
|
service selectors also require the cleanup described in
|
||||||
|
`docs/candidate-catalog-qualification.md` after final publication.
|
||||||
|
|
||||||
|
|
||||||
|
### Development Angor candidate update
|
||||||
|
|
||||||
|
The supported `package.update` RPC selected the private signed catalog and
|
||||||
|
upgraded only `angor-indexer` to the locally built 1.0.2 image. The actual dev
|
||||||
|
endpoint rendered the Mempool explorer at widths 390 and 1440 with zero failed
|
||||||
|
JavaScript/CSS requests and one WebSocket connection each. All unrelated dev
|
||||||
|
containers retained their exact IDs and start times. This verifies the local
|
||||||
|
explorer presentation, not complete blockchain indexing or recovery of the 34
|
||||||
|
missing original Angor project announcements. Bitcoin remains in IBD.
|
||||||
|
|
||||||
|
The repaired NPM namespace also reached the saved gateway,
|
||||||
|
`host.containers.internal`, and the node LAN address with the expected site
|
||||||
|
authentication response. The durable compatibility blocker remains open.
|
||||||
|
|
||||||
|
|
||||||
|
## Live Lightning purchase: Framework to Shorty — 2026-10-02
|
||||||
|
|
||||||
|
Operator explicitly authorized a very small new test purchase, then performed
|
||||||
|
it from Framework. This is separate from recovering the Amish Paradise sale.
|
||||||
|
Fixture: `archy-lightning-delivery-test-20261002.txt`, price 1 sat, Lightning only.
|
||||||
|
Read-only checks confirmed one matching seller invoice, SETTLED for exactly
|
||||||
|
1 sat, and Framework payment SUCCEEDED for 1 sat with 1 sat routing fee
|
||||||
|
(1,000 msat). Total spent was 2 sats. The assistant sent no payment.
|
||||||
|
|
||||||
|
Framework has exactly one durable purchased-content ownership entry for the
|
||||||
|
fixture, with backend `lightning`, paid_sats=1 and size_bytes=121. Its cached
|
||||||
|
file SHA256 equals the original seller fixture:
|
||||||
|
`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`.
|
||||||
|
**PASS: actual node-wallet payment, seller settlement, delivered bytes and
|
||||||
|
persisted buyer ownership/cache.** Framework runs the candidate backend
|
||||||
|
`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`;
|
||||||
|
Shorty runs `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
|
||||||
|
This also exercises the candidate buyer against the existing seller version.
|
||||||
|
|
||||||
|
Live reopen without payment and restart acceptance are not established by
|
||||||
|
this check. Shorty had no matching durable entitlement JSON in the inspected
|
||||||
|
location; do not attribute the candidate seller persistence implementation to
|
||||||
|
this older seller binary. No node or wallet was restarted. The tiny fixture
|
||||||
|
remains available for a free cached reopen check; remove only its catalog
|
||||||
|
entry/source file afterwards, preserving buyer ownership and payment records.
|
||||||
|
|
||||||
|
|
||||||
|
### Operator-confirmed free reopen — 2026-10-02
|
||||||
|
|
||||||
|
After the verified one-sat purchase, the operator reopened the file on Framework
|
||||||
|
and confirmed it worked without another payment. **PASS: live paid delivery,
|
||||||
|
durable buyer ownership/cache, and free repeat access**, with independent
|
||||||
|
settlement/byte checks above and operator confirmation of the reopen UI.
|
||||||
|
This closes that specific live acceptance check; it does not establish an
|
||||||
|
untested restart, outage, or seller-upgrade scenario.
|
||||||
|
|
||||||
|
The temporary seller catalog entry and source fixture were removed after
|
||||||
|
acceptance. Buyer purchased bytes/ownership and all payment records were preserved.
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
# Archipelago 1.9.0-alpha release acceptance
|
||||||
|
|
||||||
|
Status: **OPEN — unpublished.** Operator requires the `-alpha` suffix: final version
|
||||||
|
`1.9.0-alpha`, tag `v1.9.0-alpha`, and matching OTA/ISO artifact names. Earlier
|
||||||
|
unsuffixed candidate evidence below is historical, not a final artifact pass.
|
||||||
|
Operator selected the 1.9.0 series instead of the provisional
|
||||||
|
1.8.23-alpha. This is the current summary; retain the detailed history and all
|
||||||
|
requirements in [the regression ledger](post-1.8.22-regressions-20261001.md) and
|
||||||
|
[the earlier acceptance record](release-1.8.23-acceptance.md). No unexecuted test
|
||||||
|
is a pass. Provide the operator a node-specific action/expected-result checklist
|
||||||
|
whenever human acceptance is needed.
|
||||||
|
|
||||||
|
## Current evidence
|
||||||
|
|
||||||
|
- Alpha backend: isolated suite **1,681 passed**, zero failed, four explicit
|
||||||
|
ignores; separate container runtime suite **82 passed**. Optimized binary
|
||||||
|
SHA256 `560aa6006cd9ef8be95b1f7831cf3b53854e911622b50022bb4402ce0f8b010a`
|
||||||
|
is deployed on dev, yaya and Shorty with private rollback backups. Framework
|
||||||
|
retains the preceding A5 candidate; its earlier acceptance remains recorded.
|
||||||
|
- Frontend: **1,222 passed across 150 files**; production build and real mobile/
|
||||||
|
desktop media/menu checks pass. Dev serves index SHA256
|
||||||
|
`c18b24024a78789fe65c74c5ce27efe2125ae869016ab65e33c5a2680b543f17`.
|
||||||
|
Yaya now serves the same index and companion package; Framework retains the
|
||||||
|
preceding qualified upload UI `67de835a…`.
|
||||||
|
- Companion **0.5.34/build54**: 12 native tests, clean build, v1/v2/v3 signatures
|
||||||
|
and unchanged signer pass. Viewer and phone download are operator accepted.
|
||||||
|
Dev APK SHA256 `ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`.
|
||||||
|
Fleet publication remains part of the final release.
|
||||||
|
- NPM corrected gateway/client-IP integration: 23 Python checks and complete
|
||||||
|
disposable real-image integration passed. Catalog generator now requires both
|
||||||
|
migration-backup and legacy-gateway capabilities; its generator/drift selection
|
||||||
|
regression passes. Candidate metadata drift zero and registry trust passed.
|
||||||
|
- Cuprate/NetBird/BTCPay grouping previously passed actual yaya desktop/mobile,
|
||||||
|
hard reload and BTCPay category/icon checks. No product installs were performed.
|
||||||
|
- Real one-sat Lightning purchase, exact bytes, buyer ownership/cache and operator
|
||||||
|
free reopen passed. Original tester recovery accepted separately.
|
||||||
|
- Transparent transaction rail, compact origin-screen upload bar/cancellation and
|
||||||
|
cooperative-close controls have recorded desktop/mobile browser acceptance.
|
||||||
|
- Framework original LND startup incident is closed with operator acceptance.
|
||||||
|
|
||||||
|
## Open release gates
|
||||||
|
|
||||||
|
- [ ] **NPM:** corrected private signature, dev/yaya selection and yaya override
|
||||||
|
retirement now PASS (2026-10-05). Remaining: full boot/OTA/ISO and
|
||||||
|
full legacy-backend migration/rollback
|
||||||
|
acceptance; retain the completed
|
||||||
|
fresh/nested disposable, public staging issuance/forced renewal and actual
|
||||||
|
yaya state-preservation checks.
|
||||||
|
- [ ] **Shorty NPM:** shop certificate12/Force SSL and manual-route migration pass. Final
|
||||||
|
corrected backend restart, 302 continuous denial probes and the external
|
||||||
|
32-case security matrix pass. Remaining: packaged boot/OTA acceptance.
|
||||||
|
Preserve management containment and current public app routing.
|
||||||
|
- [ ] **Security:** verify final deployed/booted artifacts against public raw IP,
|
||||||
|
unknown Host/SNI, forged forwarding headers, IPv4/IPv6, assets/RPC/WS;
|
||||||
|
preserve private access, ACME issuance/renewal and public app TLS/WSS.
|
||||||
|
- [ ] **Fees/Bump:** deployed dev/yaya Fast UI and real isolated funded regtest
|
||||||
|
(CPFP/RBF, fee history, restart, confirmation/reorg) pass. Authenticated
|
||||||
|
Framework read-only quote/status acceptance remains. Preserve approved green UI.
|
||||||
|
No production spending or channel closure is authorized by this checklist.
|
||||||
|
- [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence
|
||||||
|
acceptance; preserve atomic ownership and safe retries without repayment.
|
||||||
|
- [x] **Uploads:** real dev/yaya interrupted-network, offset recovery, lost
|
||||||
|
replies, hashes, cancellation and compact origin-screen display pass.
|
||||||
|
Physical companion background/reconnect and Framework Cloud flow are
|
||||||
|
operator accepted. Final packaged-artifact checks remain below.
|
||||||
|
- [ ] **File Browser credentials:** unique managed login, default-password
|
||||||
|
removal, account/file preservation and rollback pass real Podman fixtures
|
||||||
|
including actual Quadlet restart. Dev/yaya/Framework migration and Cloud
|
||||||
|
acceptance pass. Remaining: packaged OTA/ISO startup. Docker behavior is not inferred from Podman fixtures.
|
||||||
|
- [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/
|
||||||
|
restarting/legacy aliases; Immich/retired-app removal and unexpected-service
|
||||||
|
identification; Portainer/Gitea migration from actual request namespace.
|
||||||
|
- [x] **UI:** category-view clear-search control passes on served dev/yaya UI
|
||||||
|
at390/1440px: click and Escape clear the field, retain focus and stay inside
|
||||||
|
the existing field. `/tmp/archy-190-final-search-live.log`. Earlier grouping
|
||||||
|
and transaction-rail results are retained.
|
||||||
|
- [ ] **Angor:** dev full-chain acceptance after unpruned Bitcoin sync; retain
|
||||||
|
the operator-accepted historical discovery limitation (34 unrecovered announcements);
|
||||||
|
recovery is follow-up work, not a publication blocker.
|
||||||
|
Publish tested explorer/API app update and optional relay in signed catalog.
|
||||||
|
- [ ] **Post-release demo deployment:** operator requests updating the existing
|
||||||
|
public software demo at https://demo.archipelago-foundation.org/ through its
|
||||||
|
established Portainer/Gitea workflow after release. Inspect the exact
|
||||||
|
stack/source, preserve rollback, verify served 1.9.0-alpha and demo flows.
|
||||||
|
This is not the Yaya v4v website or a Portainer version upgrade.
|
||||||
|
- [ ] **Final artifacts:** finish versioned build; exact candidate deployment;
|
||||||
|
OTA update/rollback and raw ISO boot/install; signature/checksum validation;
|
||||||
|
publish Git/ngit, app images/catalog and artifacts; verify public downloads
|
||||||
|
and fleet discovery; remove temporary catalog selectors after publication;
|
||||||
|
supply LAN SCP command for the raw ISO.
|
||||||
|
|
||||||
|
## Retained regression scope
|
||||||
|
|
||||||
|
Mempool version/update clearing/deduplication; Minibits and Cashu same-mint payment
|
||||||
|
handling; LND startup/Receive/unknown balances; Bitcoin warmup and IBD dashboards;
|
||||||
|
pruning and X250 kiosk picker; AIUI background; launch readiness/card geometry;
|
||||||
|
GitWorkshop; Gitea/Portainer; safe network diagnostics; operator uninstall/stop
|
||||||
|
choices; companion images and generated service configuration; radio payload and
|
||||||
|
UK MeshCore dev V3 acceptance; previously reviewed/merged PRs. Detailed original
|
||||||
|
requirements and evidence remain in the linked ledger, not silently dropped.
|
||||||
|
|
||||||
|
## Explicit boundaries
|
||||||
|
|
||||||
|
Framework V4 radio is now reported working by the operator (2026-10-05).
|
||||||
|
No reflash is requested; retain this as operator evidence, separate from automated
|
||||||
|
hardware coverage. Previously
|
||||||
|
accepted Primal comment and lost-response Cashu receipt follow-ups remain separate.
|
||||||
|
Only one Angor project has full public browser acceptance; 34 missing announcements
|
||||||
|
are not proven globally lost. Do not claim complete recovery from one fixture.
|
||||||
|
|
||||||
|
### Further live evidence
|
||||||
|
|
||||||
|
Framework's existing one-sat purchased-file ownership entry and exact 121-byte
|
||||||
|
cache remain present after the Bump management restart. Purchase timestamp
|
||||||
|
09:15:03 UTC precedes manager start 10:42:52 UTC on 2026-10-02. SHA256 remains
|
||||||
|
`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`.
|
||||||
|
This establishes buyer persisted bytes/ownership across that actual manager
|
||||||
|
restart. It does not establish a full-machine reboot or seller outage scenario.
|
||||||
|
No payment or restart was performed for this read-only check.
|
||||||
|
|
||||||
|
Yaya post-deployment checks pass: native Bitcoin/LND unchanged, private UI200,
|
||||||
|
marked public HTTP/HTTPS404, missing HTTPS challenge404, exact challenge bytes
|
||||||
|
written inside NPM over local HTTP/HTTPS and public HTTP, existing public site
|
||||||
|
trusted HTTPS/authentication preserved. This is not yet the new signed-catalog
|
||||||
|
migration without the temporary network override.
|
||||||
|
|
||||||
|
### Versioned build and final suites
|
||||||
|
|
||||||
|
The optimized 1.9.0 backend build passed; SHA256
|
||||||
|
`e1b94e6de9b5cc3dfcec16994bc3d0f710f3b7bcc6e5af045c6e53bb94b6abf0`.
|
||||||
|
The final frontend suite passed **1,187 tests in 146 files**, zero failed.
|
||||||
|
All 48 script unit tests passed, as did app build-context, manifest-shell,
|
||||||
|
ISO overlay, network-doctor, pruning and LND UI readiness checks. The release
|
||||||
|
harness now includes NPM bridge, guard, catalog capability and isolated actual
|
||||||
|
nginx security tests. All 120 public-network rejection cases passed again.
|
||||||
|
|
||||||
|
Yaya category search clearing passes desktop/mobile: click, Escape, focus and
|
||||||
|
contained icon, unchanged 40/52px field heights. Portainer's actual namespace
|
||||||
|
still reads Git smart HTTP refs and Compose from the expected branch; native
|
||||||
|
services and the production site were not changed by those probes.
|
||||||
|
|
||||||
|
Fresh Angor relay queries still recover only one of the 35 original signed
|
||||||
|
announcements. Eight relays returned results/EOSE; two archive endpoints were
|
||||||
|
unavailable. A release-scope decision was requested rather than silently waiving
|
||||||
|
this external-data requirement. The reference HTTP endpoint was readable through
|
||||||
|
Python, while the Node HTTP client received HTML; relay queries used the recorded
|
||||||
|
35 exact event IDs, and accepted only matching validly signed kind3030 events.
|
||||||
|
|
||||||
|
A new isolated runtime harness executes the production backend against actual
|
||||||
|
Bitcoin/LND regtest processes, with private process/network/filesystem namespaces,
|
||||||
|
normal account setup/login and disposable wallets. Its CPFP, child RBF, recipient,
|
||||||
|
fee-budget, duplicate-submit and backend-restart checks passed. Confirmation and reorg recovery also passed after the fixture announced a
|
||||||
|
competing empty block. The earlier disconnect-only fixture failed to notify the
|
||||||
|
expected new chain state and is retained as a failed attempt. Full run evidence:
|
||||||
|
`/tmp/archy-fee-regtest-run3.log`. No production wallets or funds were used.
|
||||||
|
|
||||||
|
### Current deployment and final history correction
|
||||||
|
|
||||||
|
The versioned backend `e1b94e6de9b5cc3dfcec16994bc3d0f710f3b7bcc6e5af045c6e53bb94b6abf0`
|
||||||
|
and the production UI are deployed on dev and yaya. Manager health200, served
|
||||||
|
index byte match and unchanged unrelated container IDs/start times passed on
|
||||||
|
both. Private rollback directories are `support/190-versioned-20261002`.
|
||||||
|
Actual category search clearing passes desktop/mobile on both nodes.
|
||||||
|
|
||||||
|
A final source audit found fee-only child history grouping was still absent.
|
||||||
|
The correction is now implemented with conservative receipt/ownership/input/
|
||||||
|
fee-only/current-chain verification, replacement-aware totals, linked fee
|
||||||
|
history and current-child Bump targeting. Nine focused UI tests and the new
|
||||||
|
production dashboard build passed. This correction is NOT in the deployed
|
||||||
|
backend above. Its isolated backend suite and extended actual regtest acceptance
|
||||||
|
remain in progress. The concurrent optimized compile was deliberately stopped
|
||||||
|
to reduce build contention and must be restarted after isolated compilation.
|
||||||
|
|
||||||
|
Corrected NPM candidate remains unsigned. The operator was given the exact
|
||||||
|
private signing command and asked for the affected physical companion route.
|
||||||
|
No publication or release-scope waiver is inferred from silence.
|
||||||
|
|
||||||
|
### Payment audit follow-up
|
||||||
|
|
||||||
|
Found a separate older Cashu repeat-download fallback that allowed a new spend
|
||||||
|
when an ownership record existed but its cached bytes were missing; an unreadable
|
||||||
|
index was also treated as empty. The payment guard now reads ownership strictly
|
||||||
|
and returns a recovery error before mint/spend in either case. Successful cache
|
||||||
|
hits retain the zero-payment response and same-seller filename alias handling.
|
||||||
|
The new regression exercises first purchase, exact/alias cache hits, different
|
||||||
|
seller, missing bytes and damaged index preservation. Final suite/rebuild are
|
||||||
|
running; no live wallet was modified. Previously documented lost-response ecash
|
||||||
|
receipt limitations remain separate from this correction.
|
||||||
|
|
||||||
|
Framework read-only optional Files-copy verification could not proceed because
|
||||||
|
the SSH control connection expired and BatchMode login was rejected. Existing
|
||||||
|
buyer cache/restart evidence remains valid; no password or account was changed.
|
||||||
|
|
||||||
|
Actual served Fast-send controls pass on dev/yaya at390/1440px: initial Fast,
|
||||||
|
explicit Standard selection and reopen reset to Fast. No spending RPC submitted.
|
||||||
|
Two earlier harness attempts had ambiguous Close/Send locators during modal
|
||||||
|
transitions; the corrected final run passes all four cases. This is send-form
|
||||||
|
acceptance, not a real cooperative-close transaction or omitted-fee wallet spend.
|
||||||
|
|
||||||
|
Final isolated suite after fee-history and missing-cache payment corrections:
|
||||||
|
**1,668 passed, zero failed, four explicit hardware/external ignores**. The
|
||||||
|
optimized build and extended real-regtest run are chained in
|
||||||
|
`/tmp/archy-190-complete-validation.py`; log
|
||||||
|
`/tmp/archy-190-complete-validation.log`. They have not yet completed.
|
||||||
|
The packaged radio flasher self-test also passes (`archy-esptool4.8.1`,
|
||||||
|
ESP32-S3 stub ready); this does not change Framework radio deferral.
|
||||||
|
|
||||||
|
## Signed qualification completed — 2026-10-05
|
||||||
|
|
||||||
|
Operator confirmed signing; exact private catalog verifies against the pinned
|
||||||
|
release root. Final optimized backend SHA256
|
||||||
|
`cfddec834a53609f8bef924f3905da76df45f22426cac2628c4c2cb06bea5d09`
|
||||||
|
and final dashboard index SHA256
|
||||||
|
`4b8f6ceb4ebe1e3b8ce1a0786f3e8a9d38e6d42ba174bf64bd54f4b23d7c13ff`
|
||||||
|
are now deployed on dev and yaya. Both health checks, served byte matches and
|
||||||
|
unrelated container identity/start-time checks passed. Root-only rollback
|
||||||
|
directories: `support/190-final-20261005-20261002` (literal generated name).
|
||||||
|
Both cached catalogs exactly match the new signed candidate.
|
||||||
|
|
||||||
|
The optimized actual Bitcoin/LND regtest passed with the new history assertions:
|
||||||
|
CPFP, child replacement, unchanged recipient, bounded fee, duplicate submission,
|
||||||
|
one payment with replacement-aware fee history, backend restart, confirmation
|
||||||
|
and reorg. No production funds were spent. Log: `/tmp/archy-fee-regtest-run4.log`.
|
||||||
|
|
||||||
|
Yaya selected the managed legacy-compatible gateway from the signed variant.
|
||||||
|
The temporary `90-qualification-host-gateway.conf` was backed up and removed
|
||||||
|
only after inspecting the generated managed network. NPM restarted successfully.
|
||||||
|
Complete selected DB tables and certificate bytes match the private baseline;
|
||||||
|
loopback and existing tunnel publications remain intact, admin API is healthy,
|
||||||
|
and an actual NPM-namespace upstream request returns the expected authenticated
|
||||||
|
site response. A private managed migration archive exists.
|
||||||
|
|
||||||
|
A subsequent manager restart and120 seconds of repeated reconciliation retained
|
||||||
|
all container identities/start times and did not recreate the removed override.
|
||||||
|
Migration checks passed again. Logs: `/tmp/archy-190-npm-override-retirement.log`
|
||||||
|
and `/tmp/archy-190-npm-persistence.log`. This is not full-machine reboot evidence.
|
||||||
|
|
||||||
|
Post-migration public integration passes: exact challenge bytes from NPM on
|
||||||
|
local HTTP/HTTPS and public HTTP, missing HTTPS challenge404, private UI200,
|
||||||
|
public-marked management HTTP/HTTPS404, public application trusted TLS and
|
||||||
|
authentication retained. Portainer's actual namespace reads Git refs and Compose
|
||||||
|
at verified tip `3ae171d6b0c728665a860520fe393c0abb772798`. Native Bitcoin/LND
|
||||||
|
unchanged. Deployed Fast-default/reopen/slower-select browser checks pass on
|
||||||
|
both nodes at390/1440px, without submitting transactions.
|
||||||
|
|
||||||
|
Additional external IPv4 probes from Shorty passed24 raw-IP/unknown/forged-host
|
||||||
|
cases with forged forwarding headers and root/RPC/assets/WebSocket paths.
|
||||||
|
Important boundary: the public front gateway returns its static Default Site
|
||||||
|
for unknown HTTP roots, rejects assets/RPC/WS with400/404, and rejects unknown
|
||||||
|
TLS names during handshake. Those are not dashboard responses. The first
|
||||||
|
harness required404 everywhere and failed on that public Default Site; retained
|
||||||
|
logs record the corrected interpretation. These probes validate the deployed
|
||||||
|
public gateway path, not direct WAN access to the node nginx. External IPv6
|
||||||
|
remains unverified; prior isolated IPv4/IPv6 guard tests remain separate.
|
||||||
|
No Shorty nginx/NPM configuration was changed.
|
||||||
|
|
||||||
|
Remaining operator inputs: normal Shorty NPM shop SSL ownership correction
|
||||||
|
(existing admin login unavailable), affected physical companion upload route,
|
||||||
|
and the retained Angor34-announcement recovery/release-scope requirement.
|
||||||
|
OTA/catalog publication, final ISO build/boot and fleet discovery remain held.
|
||||||
|
|
||||||
|
Read-only dev chain check2026-10-05: unpruned Bitcoin at830743/970017, verification progress0.63846, IBD true, warnings empty. Full-chain Angor acceptance remains pending sync; no service or wallet change made.
|
||||||
|
|
||||||
|
## Operator checks accepted; upload UX amendment — 2026-10-05
|
||||||
|
|
||||||
|
Operator reports the requested human checks worked perfectly: Shorty shop SSL,
|
||||||
|
physical upload flow and Framework dashboard/purchased-file checks. This is
|
||||||
|
operator acceptance, not a claim of newly independent device testing. Read-only
|
||||||
|
Shorty verification confirms shop certificate_id12 and Force SSL enabled.
|
||||||
|
Remaining migration/artifact/security and Angor requirements still apply.
|
||||||
|
|
||||||
|
Operator supersedes the globally persistent upload-bar requirement: keep the
|
||||||
|
bar only on the screen where the batch originated, continue transfers across
|
||||||
|
navigation, show explicit Complete on successful server save, and use a
|
||||||
|
completion notification elsewhere. Source now retains the originating route,
|
||||||
|
removes the global floating bar, keeps the original44px inline bar, and reports
|
||||||
|
success/error/cancellation distinctly.25 focused store/component/notification
|
||||||
|
tests pass. The subsequent full frontend suite passed1,193 tests; production
|
||||||
|
build and actual served desktop/mobile real-upload checks passed. Deployed to
|
||||||
|
dev/yaya with index SHA256
|
||||||
|
`86bb728017b118d8e98f032419e7fbfd6ecd78b7e464c982a2075cc38c582814`;
|
||||||
|
no apps or backend services restarted. Retain this as the preceding UI evidence,
|
||||||
|
not evidence for the later resumable-upload implementation. No new payment was requested or performed.
|
||||||
|
|
||||||
|
### Resumable upload addition — 2026-10-05
|
||||||
|
|
||||||
|
Operator requests recovery after a background pause or connection loss. The
|
||||||
|
installed File Browser identifies as2.63.23/e8a388f8 and supports TUS. Cloud now
|
||||||
|
has a candidate chunked upload implementation: random same-folder staging path,
|
||||||
|
server-offset reconciliation, transient retry/online/visibility recovery,
|
||||||
|
cancellation, final SHA256 verification and rename. Lost final chunk/rename
|
||||||
|
responses are reconciled without restarting or accepting a same-size old file.
|
||||||
|
The original-screen-only44px bar, Complete label and off-screen notification
|
||||||
|
remain. Fifteen focused protocol tests pass; full build/deployed fault injection
|
||||||
|
are in progress. This is not yet live acceptance.
|
||||||
|
|
||||||
|
Recovery requires the selected File to remain available in the running page.
|
||||||
|
An OS-killed app or expired server upload session may require reselecting the
|
||||||
|
file. Do not promise uninterrupted background execution or restart persistence.
|
||||||
|
This gate is additional to the already accepted physical upload flow.
|
||||||
|
|
||||||
|
## ngit PR integration — 2026-10-05
|
||||||
|
|
||||||
|
Both requested proposals are merged and pushed to Gitea and ngit main at
|
||||||
|
`2c1bcacf`; ngit independently reports both as `applied`.
|
||||||
|
|
||||||
|
- `494d2483`: opt-in NODE_IDENTITY_PUBKEYS for app owner allow-lists. Review
|
||||||
|
corrected ECMAScript/Rust whitespace differences and added strict public-key
|
||||||
|
validation. Appliance identity excluded; no private keys or signing capability
|
||||||
|
given to apps. Existing manifests and the native signing flow are unchanged.
|
||||||
|
Documentation explicitly describes linking all offered user identities.
|
||||||
|
- `c18ebd7f`: nostr0.44.7 and nostr-relay-pool0.44.3. The standalone relay pool's
|
||||||
|
maintenance advisory remains; SDK0.45 migration is a separate follow-up.
|
||||||
|
- Combined isolated backend suite:1,678 passed, zero failed,4 explicit ignores.
|
||||||
|
Real loopback hostile-relay test rejects altered content, author and signature
|
||||||
|
reusing a known DB event ID while accepting a valid event. NIP04/NIP44 normal
|
||||||
|
encryption and hostile/oversized payload tests pass. The initial relay harness
|
||||||
|
returned before connection establishment; corrected to wait for an actual
|
||||||
|
connection before fetch, and the complete rerun passes.
|
||||||
|
- Evidence: /tmp/archy-190-ngit-complete-tests.log, origin/ngit push logs and
|
||||||
|
/tmp/archy-190-ngit-postmerge.json. This is source publication, not OTA/ISO or
|
||||||
|
catalog publication. Later File Browser credential changes need a new suite.
|
||||||
|
|
||||||
|
## File Browser secure automatic login — NEW REQUIRED GATE
|
||||||
|
|
||||||
|
Operator requests unique per-node credentials, working Cloud from first launch,
|
||||||
|
no admin/admin and fleet-wide testing. Framework's reported authentication issue
|
||||||
|
recovered, which is not proof that this gate is fixed. Yaya rejects the saved
|
||||||
|
password with403 despite healthy File Browser2.63.23. Never count that as a
|
||||||
|
passed upload test.
|
||||||
|
|
||||||
|
Confirmed source issues: first-boot paths still try noauth/admin defaults; the
|
||||||
|
post-install hook assumes admin/admin and uses an incompatible password-change
|
||||||
|
request shape; the generated ISO path updates a running DB and uses a different
|
||||||
|
DB filename; Cloud hardcodes admin and invents admin/admin on missing secrets.
|
||||||
|
|
||||||
|
Candidate scripts/filebrowser-credentials.py now provisions a random username
|
||||||
|
and256-bit password offline with the pinned app image, backs up the selected
|
||||||
|
DB/config, preserves custom accounts, tests automatic login plus folder access
|
||||||
|
in a network-isolated container, rejects unauthenticated access, rotates only a
|
||||||
|
proven admin/admin login, and atomically publishes a0600 credential record.
|
||||||
|
Fresh real-image acceptance passes. Legacy/default/custom/restart/rollback,
|
||||||
|
first-boot/Quadlet/runtime wiring, live yaya/dev/Framework qualification and final
|
||||||
|
artifacts remain OPEN. No live File Browser account or DB has been modified.
|
||||||
|
|
||||||
|
Upload resume: source/build/full frontend1,208 tests passed; subsequent48 focused
|
||||||
|
protocol/client tests passed after filename escaping correction. UI deployed on
|
||||||
|
dev/yaya index SHA256
|
||||||
|
`31ac7bcc704c18f88a8b9800fb46bc7941651983e1a99b97d036a8d9e95a58b5`.
|
||||||
|
Actual dev1440/390px real-server fault injection passed partial offset123456,
|
||||||
|
offline reconnect, lost final PATCH and rename replies, exactSHA256, encoded
|
||||||
|
filenames, original-screen-only44px bar, notification, cancel and empty files.
|
||||||
|
Yaya is blocked at the credential gate above. Physical suspended/killed-app
|
||||||
|
acceptance is not inferred from these viewport tests.
|
||||||
|
|
||||||
|
## 2026-10-05 resumed release qualification
|
||||||
|
|
||||||
|
- Latest full frontend: 1,210 tests passed across 148 files. Production Cloud UI
|
||||||
|
and AIUI builds passed. Dev and yaya serve index SHA256
|
||||||
|
`3e10a25db75e4712310eb34c98bf7595ad5db3a444f9126a73715b1d40493a33`;
|
||||||
|
UI archive SHA256 `586d1864c5c4027086194f6b5951a9b770c4e7ce9ba9ec3128e2ac0c1bde55e7`.
|
||||||
|
Private UI backups: `/var/lib/archipelago/support/cloud-auth-20261005`.
|
||||||
|
- Real dev browser upload tests pass at 1440/390px, including interrupted JWT
|
||||||
|
refresh, partial write, offline recovery, lost final PATCH/rename responses,
|
||||||
|
exact SHA256, encoded filenames, cancellation, empty file, origin-only 44px
|
||||||
|
bar and completion notification. Initial run overlapped UI deployment and
|
||||||
|
failed navigation/bar timing; kept as failed evidence. Clean rerun explicitly
|
||||||
|
verifies successful navigation and passes both viewports. Physical OS suspension
|
||||||
|
and yaya authentication/upload acceptance remain separate gates.
|
||||||
|
- Real File Browser image matrix passed fresh, legacy-default, legacy-custom,
|
||||||
|
legacy-noauth and forced-failure exact DB rollback. Existing file bytes and
|
||||||
|
user IDs/permissions preserved; custom credentials preserved; admin/admin and
|
||||||
|
anonymous access rejected. Actual disposable Quadlet pre-start and restart
|
||||||
|
also pass, with stable managed credentials. Four Python unit tests pass.
|
||||||
|
- File Browser startup integration now covers the direct runtime, Quadlet,
|
||||||
|
first boot and ISO script. Binary bootstrap installs its matching helper before
|
||||||
|
reconciliation. Fixed bundled first-boot missing NET_BIND_SERVICE and duplicate
|
||||||
|
creation attempt for a stopped File Browser. Live credential migration is still
|
||||||
|
pending the optimized backend build; no production DB/account modified yet.
|
||||||
|
- Final combined isolated backend suite: 1,681 passed, zero failed, four ignored.
|
||||||
|
An earlier run failed the Nostr relay fixture after a normal ping closed its
|
||||||
|
text-only receive loop. Fixed the fixture to answer pings; the complete rerun
|
||||||
|
passes. No failed run is counted as acceptance.
|
||||||
|
- NPM: 23 Python tests pass, including exact emergency BTCPay route recognition,
|
||||||
|
operator edit preservation, missing certificate/alias refusal and transactional
|
||||||
|
rollback. Existing emergency Angor routes now also require complete TLS
|
||||||
|
replacements before retirement. Actual disposable flat-layout NPM integration
|
||||||
|
passed namespace reachability, legacy gateway, ACME exact bytes, forced HTTPS,
|
||||||
|
WSS, certificate replacement, password/network ACLs and forged-header rejection,
|
||||||
|
restart, disable/delete, and forced bind-failure restoration. This is not a
|
||||||
|
staging-CA issuance/renewal or ISO/reboot pass.
|
||||||
|
- Shorty read-only inspection confirms shop certificate12 and Force SSL with both
|
||||||
|
hostname aliases; old manual shop route still uses certificate10. No live
|
||||||
|
Shorty routing change in this qualification. Migration remains pending.
|
||||||
|
- Evidence logs: `/tmp/archy-190-final-combined-backend.log`,
|
||||||
|
`/tmp/archy-190-cloud-auth-ui-dev-live-2.log`,
|
||||||
|
`/tmp/archy-190-filebrowser-final-integration.log`,
|
||||||
|
`/tmp/archy-190-filebrowser-quadlet.log`,
|
||||||
|
`/tmp/archy-190-npm-final-integration.log`.
|
||||||
|
- OTA/catalog/raw ISO publication remains held. Framework radio deferred;
|
||||||
|
Angor 34 unrecovered original announcements and dev full-chain acceptance
|
||||||
|
remain open. README alpha/funds notice is separately published to both remotes.
|
||||||
|
|
||||||
|
Additional qualification: real nested-layout NPM integration passed the same
|
||||||
|
namespace/ACME/TLS/WSS/access-control/restart/rollback matrix as flat layout
|
||||||
|
(`/tmp/archy-190-npm-final-nested-integration.log`). Container crate isolated
|
||||||
|
suite: 82 passed, zero failed. Corrected Nostr hostile-relay test passed a separate
|
||||||
|
isolated repeat (`/tmp/archy-190-nostr-relay-repeat.log`). Dev Bitcoin read-only
|
||||||
|
status: height832232 of970036, verification0.641006, IBDtrue, prunedfalse. Full-chain
|
||||||
|
Angor acceptance therefore remains blocked on synchronization, not passed.
|
||||||
|
|
||||||
|
## Live File Browser ownership regression — publication hold
|
||||||
|
|
||||||
|
2026-10-05 dev candidate backend SHA256
|
||||||
|
`3e01da72fcea0a61852f3d9038e67630e328c65d6433da749671d60b91c37ffa`
|
||||||
|
built successfully, then failed live credential migration before DB mutation.
|
||||||
|
The helper could not create its private backup under the legacy data-directory
|
||||||
|
owner (host UID100000). The original real-image fixtures aligned data ownership
|
||||||
|
to the image UID and therefore missed the shipped manifest's different mapping.
|
||||||
|
The managed File Browser has DAC_OVERRIDE for that layout; the helper did not.
|
||||||
|
|
||||||
|
Restored prior backend SHA256
|
||||||
|
`cfddec834a53609f8bef924f3905da76df45f22426cac2628c4c2cb06bea5d09`
|
||||||
|
and original File Browser Quadlet with the staged rollback script. Both services
|
||||||
|
are active. Yaya backend was not changed. No candidate credential record was
|
||||||
|
published; failed setup stopped at backup-directory creation before DB changes.
|
||||||
|
|
||||||
|
Source helper now includes the managed server's DAC_OVERRIDE storage capability;
|
||||||
|
new real-image legacy-owner and actual-Quadlet fixtures reproduce that mapping.
|
||||||
|
Rollback fixture now forces an account-policy failure after noauth migration so
|
||||||
|
it still verifies restoration after a real DB mutation. These revised tests and
|
||||||
|
combined backend validation are in progress. A corrected embedded-helper build
|
||||||
|
and new live qualification remain required. Do not reuse the failed binary as
|
||||||
|
final release or mark the credential gate passed from earlier fixture results.
|
||||||
|
|
||||||
|
Release-note drift corrected to1.9.0/current upload behavior and File Browser/
|
||||||
|
Nostr additions. The checker now rejects stale descriptions/dates for an existing
|
||||||
|
version; its regression passes. Latest notes UI built and deployed dev/yaya index
|
||||||
|
SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`.
|
||||||
|
Phone background/reconnect acceptance question is pending, not passed.
|
||||||
|
|
||||||
|
## Corrected credential qualification and mirror policy — 2026-10-05
|
||||||
|
|
||||||
|
The DAC_OVERRIDE correction passed all six real-image cases, including legacy
|
||||||
|
manifest ownership and restoration after an actual DB mutation. Actual disposable
|
||||||
|
Quadlet first start/restart passed with legacy ownership. Corrected helper SHA256
|
||||||
|
`e9e2fd94534130f10f19f81ebe0d4e382dca4338118393c7d1e30535a8478eb5`
|
||||||
|
also migrated the dev node's actual File Browser storage successfully: managed
|
||||||
|
login/folder200, private credential record, unchanged unrelated containers, and
|
||||||
|
manager/File Browser restored active. The old backend remains deployed pending
|
||||||
|
the corrected optimized build. Yaya credential/backend acceptance remains open.
|
||||||
|
|
||||||
|
Evidence: `/tmp/archy-190-filebrowser-ownership-integration.log`,
|
||||||
|
`/tmp/archy-190-filebrowser-ownership-quadlet.log`,
|
||||||
|
`/tmp/archy-190-filebrowser-ownership-live-dev.log`,
|
||||||
|
`/tmp/archy-190-filebrowser-ownership-dev-cloud.log`.
|
||||||
|
Updated isolated backend suite: 1,681 passed, zero failed, four ignored
|
||||||
|
(`/tmp/archy-190-filebrowser-ownership-backend.log`).
|
||||||
|
Browser protocol recovery also passes explicit CDP frozen-page/offline/reconnect
|
||||||
|
at both widths (`/tmp/archy-190-cloud-frozen-dev.log`); physical phone acceptance
|
||||||
|
is still pending and is not inferred from browser automation.
|
||||||
|
|
||||||
|
Operator selected ngit as the canonical contribution/review platform; Gitea
|
||||||
|
mirrors accepted main and release tag objects without requiring duplicate PRs.
|
||||||
|
Rule, contributor docs and read-only parity gate are committed as `138a541d`,
|
||||||
|
pushed to both mirrors and main/local parity verified. Disposable bare-repository
|
||||||
|
regression covers missing refs, partial pushes, divergence, annotation drift,
|
||||||
|
unpublished local commits, intentionally separate branches and inaccessible
|
||||||
|
remotes. Final release gate must additionally check the actual release tag.
|
||||||
|
|
||||||
|
## Alpha candidate: live Cloud and ACME qualification — 2026-10-05
|
||||||
|
|
||||||
|
Operator requires final version **1.9.0-alpha** and tag **v1.9.0-alpha**. Cargo,
|
||||||
|
frontend package/lock, changelog and What's New now agree; the unused unsuffixed
|
||||||
|
What's New block was removed. The optimized alpha build is in progress. Current
|
||||||
|
backend qualification SHA256 `e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf`
|
||||||
|
is deployed on dev and yaya but predates this suffix change; it is not the final
|
||||||
|
artifact. Both returned backend health200 and managed Cloud login/folder200 with
|
||||||
|
unrelated container IDs/start times unchanged.
|
||||||
|
|
||||||
|
A real upload rerun initially failed after concurrent token refresh. A new unit
|
||||||
|
regression reproduced the race: mutable shared failure state let another login
|
||||||
|
turn a network interruption into a credential rejection. Authentication now
|
||||||
|
shares an in-flight request and returns its own retryability result. Regression
|
||||||
|
failed before and passes after. Full frontend: **1,211 passed / 148 files**.
|
||||||
|
Full alpha backend isolated suite: **1,681 passed, zero failed, four ignored**.
|
||||||
|
Deployed alpha UI index SHA256 on dev/yaya:
|
||||||
|
`67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`.
|
||||||
|
Both real browser upload suites pass 390/1440px including partial writes, frozen
|
||||||
|
page/offline return, interrupted refresh, lost final replies, exact saved hash,
|
||||||
|
encoded filenames, origin-only bar, completion notification and cancellation.
|
||||||
|
|
||||||
|
Framework access was restored with the supplied updated SSH credential. Its
|
||||||
|
LND reports chain/graph sync; balance and channel queries work. Confirmed the
|
||||||
|
legacy File Browser still accepted admin/admin, then applied the exact qualified
|
||||||
|
helper with a private backup and bounded File Browser/manager stop-start. Both
|
||||||
|
managed and compatibility logins/folder reads200; admin/admin403; credential mode
|
||||||
|
0600; all other container IDs/start times unchanged. Prior backend retained until
|
||||||
|
final alpha deployment. Dashboard RPC session needs second-factor login; no
|
||||||
|
wallet funds were spent. Operator now reports Framework radio working; no reflash.
|
||||||
|
|
||||||
|
Local Pebble ACME **fresh and legacy nested layouts passed** actual pre-host
|
||||||
|
issuance, HTTP challenges, forced-HTTPS renewal, new certificate served, unknown
|
||||||
|
management404, trusted WSS, access controls, restart and forced-bind rollback.
|
||||||
|
Fixture fixes: modern NPM meta schema; explicit slirp loopback CA route; disable
|
||||||
|
random test-CA nonce rejection for deterministic route/renewal coverage. This is
|
||||||
|
an isolated test CA, not a public Let's Encrypt staging/ISO/reboot pass. All test
|
||||||
|
containers were cleaned up. Source NPM regression remains23/23.
|
||||||
|
|
||||||
|
Evidence: `/tmp/archy-190-alpha-backend-tests.log`,
|
||||||
|
`/tmp/archy-190-alpha-frontend-tests.log`,
|
||||||
|
`/tmp/archy-190-cloud-concurrent-login-before.log`,
|
||||||
|
`/tmp/archy-190-cloud-concurrent-login-after.log`,
|
||||||
|
`/tmp/archy-190-alpha-cloud-dev.log`, `/tmp/archy-190-alpha-cloud-yaya.log`,
|
||||||
|
`/tmp/archy-190-framework-secure-cloud.log`,
|
||||||
|
`/tmp/archy-190-framework-cloud-compatibility.log`,
|
||||||
|
`/tmp/archy-190-npm-acme-flat-6.log`, `/tmp/archy-190-npm-acme-nested.log`.
|
||||||
|
|
||||||
|
Public demo target clarified: https://demo.archipelago-foundation.org/, currently
|
||||||
|
reported1.8.8. Existing Docker Compose demo deployment located read-only; do not
|
||||||
|
confuse it with Yaya's v4v stack. Update after release, preserving rollback and
|
||||||
|
qualifying mock backend compatibility with new Cloud uploads. No demo deployed yet.
|
||||||
|
No OTA/catalog/ISO has been published.
|
||||||
|
|
||||||
|
## 2026-10-05 additional mobile media and file-action qualification
|
||||||
|
|
||||||
|
Operator accepted both physical phone upload recovery and Framework Cloud
|
||||||
|
folder/upload/open checks. These manual gates are closed.
|
||||||
|
|
||||||
|
A subsequent Cloud screenshot and touch-action report introduced new release
|
||||||
|
requirements: safe-area-aware photo/video viewing, separated touch controls,
|
||||||
|
fullscreen/exit, permanent translucent file-card actions, and the same actions
|
||||||
|
inside the viewer. Source and component tests are in the regression ledger.
|
||||||
|
The complete updated frontend suite passes: **1,222 tests in 150 files**.
|
||||||
|
Production frontend build passes. Chromium checks cover phone portrait,
|
||||||
|
landscape and desktop geometry, native fullscreen, action-menu access while
|
||||||
|
fullscreen, video decoding/playback, no accidental preview from a menu tap,
|
||||||
|
and cancellation before deletion. The deployed dev box reads the operator's
|
||||||
|
actual screenshot and 4K video without changing either file.
|
||||||
|
|
||||||
|
The new Android fullscreen callback implementation compiles and its three
|
||||||
|
Robolectric lifecycle tests pass with zero failures/errors. It still requires a
|
||||||
|
clean APK, signature verification and physical companion acceptance; compilation
|
||||||
|
and browser fullscreen do not establish that acceptance. Version 0.5.33/build53
|
||||||
|
is reserved for the companion update. No native fullscreen APK published yet.
|
||||||
|
|
||||||
|
Final OTA/frontend/ISO checksums and source attribution must be regenerated after
|
||||||
|
these additions. Candidate ISO build215 is superseded for publication purposes.
|
||||||
|
Previously recorded NPM fleet migration, exact signed OTA/rollback and ISO install
|
||||||
|
qualification, full-chain/Angor scope, mirror parity and public-demo requirements
|
||||||
|
remain open unless separately closed by direct evidence. No prior publication
|
||||||
|
hold is waived by the mobile test results.
|
||||||
|
|
||||||
|
### Companion download qualification update
|
||||||
|
|
||||||
|
Operator accepted the viewer but reported companion-only download failure.
|
||||||
|
Browser download of the same file matches its exact bytes. Added native saving
|
||||||
|
through Android's system file picker with authentication, streamed progress,
|
||||||
|
cancellation and error cleanup. The clean download/fullscreen suite passes all
|
||||||
|
12cases. Canonical clean companion0.5.34/build54 packaging passes v1/v2/v3 and
|
||||||
|
existing-signer verification. Dev serving is verified byte-for-byte with SHA256
|
||||||
|
`ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`.
|
||||||
|
The operator reports “works, we can proceed” after the phone save/open/cancel
|
||||||
|
check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads;
|
||||||
|
only the dev-box test download is updated now. No fleet/public release is claimed.
|
||||||
|
|
||||||
|
### Demo upload compatibility — 2026-10-05
|
||||||
|
|
||||||
|
The prior demo backend lacked TUS, returned folder metadata for completed files,
|
||||||
|
and only accepted JSON-body renames. Added the actual Cloud upload protocol with
|
||||||
|
per-visitor sessions, reserved-byte quota, committed offsets, checksum metadata,
|
||||||
|
query-based rename and partial cancellation. Literal encoded filenames survive
|
||||||
|
unchanged. Deleting seeded files cannot subtract uncharged bytes from the quota;
|
||||||
|
deleting a folder releases its pending upload reservations.
|
||||||
|
|
||||||
|
Four real HTTP scenarios run the production `resumableUpload` client against an
|
||||||
|
isolated demo process and pass: lost chunk/save responses with exact5MiB+123bytes,
|
||||||
|
visitor isolation, zero-byte/replacement/cancellation, simultaneous quota, stale
|
||||||
|
offsets/oversized chunks and a real interrupted TCP request. The first run's10s
|
||||||
|
cold-start limit failed before startup; the bounded60s run passes. Evidence:
|
||||||
|
`/tmp/archy-190-demo-upload-final-2.log`.
|
||||||
|
|
||||||
|
The existing mesh/federation parity harness initially discarded demo cookies,
|
||||||
|
creating a new visitor on every request. It now retains its session and always
|
||||||
|
runs demo-only on loopback, never against the live container runtime. It also
|
||||||
|
found two newly missing radio configuration handlers. Demo now explicitly reports
|
||||||
|
hardware unavailable and refuses to claim an applied radio configuration. The
|
||||||
|
full parity run passes, including those assertions:
|
||||||
|
`/tmp/archy-190-demo-rpc-parity-final-2.log`. Both checks are release harness stages.
|
||||||
|
These changes are locally qualified; the public demo remains unchanged pending
|
||||||
|
final release, fresh AIUI packaging and deployed browser acceptance.
|
||||||
|
|
||||||
|
### Yaya accepted mobile candidate deployment
|
||||||
|
|
||||||
|
The same dev UI index `c18b2402…` and signed companion0.5.34/build54 APK
|
||||||
|
`ceb58a7d…` are now served on Yaya. All75 changed files match their reviewed
|
||||||
|
source hashes, including HTTP-served index/APK/metadata. Container identities
|
||||||
|
and start times are unchanged; the qualified catalog and AIUI are preserved.
|
||||||
|
Private rollback backup: `/var/lib/archipelago/support/190-mobile-20261005`.
|
||||||
|
Evidence: `/tmp/archy-190-yaya-mobile-deploy.log`. Phone acceptance was on the dev
|
||||||
|
APK; this byte-identity deployment check is not another physical-phone test.
|
||||||
|
|
||||||
|
Source review proposal: [ngit5957be8c](https://gitworkshop.dev/nevent1qqs9j4a73jyu6xfrpzrqcx2tqkldnuc8wzfas2zdkq6s2qlvftdaakqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq8s3xt),
|
||||||
|
covering daac47ca,5aa74d05,b8266c28,ba8b1f29. Proposal publication succeeded;
|
||||||
|
remote main refs and release tags have not been advanced. Do not call it merged
|
||||||
|
or the mirrors synchronized from proposal upload alone.
|
||||||
|
|
||||||
|
Private final NPM catalog candidate is ready for the operator's signature.
|
||||||
|
Compared with the previously signed candidate, only NPM's variant version2.14.0,
|
||||||
|
immutable image digest and the catalog timestamp changed.64 apps/63 manifests,
|
||||||
|
zero drift, registry trust and the pinned-image integration pass. This signature
|
||||||
|
is for migration qualification, not full-release acceptance or publication.
|
||||||
|
The operator was separately asked to resolve Angor's outstanding discovery scope.
|
||||||
|
|
||||||
|
Yaya post-deployment browser checks pass at390/1440px for grouping, icons, hard
|
||||||
|
refresh and BTCPay Commerce-only placement. The first harness session had an
|
||||||
|
expired login cookie and used catalog fallback; after normal login, the signed
|
||||||
|
catalog endpoint returns200/64apps and the complete rerun passes without401.
|
||||||
|
Evidence: `/tmp/archy-190-yaya-final-ui-smoke-authenticated.log`.
|
||||||
|
|
||||||
|
## Shorty live qualification: cached-runtime guard regression — 2026-10-05
|
||||||
|
|
||||||
|
The operator signed the final NPM candidate. Release-root verification and exact
|
||||||
|
reviewed payload comparison pass; signed SHA256
|
||||||
|
`479f6193835a16dd4ab167e5c22306a878ac39b77c2e2793971e807874fbc0cb`.
|
||||||
|
This signature authorizes private qualification; it is not release publication.
|
||||||
|
|
||||||
|
Shorty baseline public shop/www/indexer/relay trusted HTTPS passes. Its prior
|
||||||
|
NPM image bytes match the pinned2.14.0 image. Consistent stopped-NPM state,
|
||||||
|
backend, unit, nginx, helpers and app metadata were backed up under
|
||||||
|
`/var/lib/archipelago/support/190-npm-20261005`. Migration reached the new private
|
||||||
|
network/listeners but failed the guard acceptance check and was rolled back.
|
||||||
|
The test initially expected the emergency guard's legacy variable; further
|
||||||
|
inspection found a real source defect, not merely that assertion mismatch.
|
||||||
|
|
||||||
|
Confirmed cause: `ensure_runtime_assets_ready` applies the management guard,
|
||||||
|
then `run_runtime_assets` installs the cached OTA's nginx template verbatim.
|
||||||
|
Shorty's cached template predates the guard. It overwrote protection before a
|
||||||
|
subsequent nginx reload; restoring the older backend repeated that path. A live
|
||||||
|
public IPv4 root probe returned200. Immediate containment applied the tested
|
||||||
|
source guard; HTTP/HTTPS root and HTTP RPC again return404. The cached legacy
|
||||||
|
runtime template is now also guarded, with its original saved privately, so
|
||||||
|
that old startup installer cannot remove protection on restart. Both emergency
|
||||||
|
and current guards are present in the active configuration. Do not claim this
|
||||||
|
attempt passed or that the broader migration is complete.
|
||||||
|
|
||||||
|
Source fix: runtime installation now renders/validates the guarded candidate
|
||||||
|
before atomic replacement under the nginx transaction lock; syntax/reload
|
||||||
|
failure restores the previous protected bytes. Rollback protects the restored
|
||||||
|
runtime template before permitting an older binary to start.11 focused tests
|
||||||
|
pass; real isolated nginx verifies the actual legacy install, old-binary copy,
|
||||||
|
invalid-template rollback, public IPv4/IPv6 denial, ACME/private access and the
|
||||||
|
existing120-case Host/SNI/forwarded-header/UI/assets/RPC/WS matrix.
|
||||||
|
The first backend suite passed1,681/0failed/4ignored before the final rollback
|
||||||
|
addition; final rerun and optimized build are required. Logs:
|
||||||
|
`/tmp/archy-190-guard-runtime-final-unit.log`,
|
||||||
|
`/tmp/archy-190-guard-runtime-final-network.log`,
|
||||||
|
`/tmp/archy-190-shorty-activation.log` (failed attempt),
|
||||||
|
`/tmp/archy-190-shorty-prepare.log`.
|
||||||
|
|
||||||
|
Only NPM's container restarted; Bitcoin, LND, ElectrumX, Angor indexer and relay
|
||||||
|
IDs/start times are unchanged. Restored shop/www/indexer/relay HTTPS returns200.
|
||||||
|
Shorty's old backend remains active under containment. Rebuild and requalify the
|
||||||
|
migration, external security and restart persistence before closing this gate.
|
||||||
|
The signed catalog contents are unchanged and need no further operator signature.
|
||||||
|
|
||||||
|
### NPM multi-domain TLS regression found by public acceptance
|
||||||
|
|
||||||
|
After the private-listener migration, local first requests passed, but public
|
||||||
|
Angor health intermittently returned502. Host nginx recorded upstream certificate
|
||||||
|
hostname mismatches when different public domains used the same NPM TLS listener.
|
||||||
|
The generated bridge inherited upstream TLS session reuse. This matches nginx's
|
||||||
|
[documented cross-SNI session-cache behaviour](https://trac.nginx.org/nginx/ticket/1340).
|
||||||
|
|
||||||
|
The bridge now explicitly sets `proxy_ssl_session_reuse off` while retaining
|
||||||
|
SNI, hostname/chain verification and the existing trusted certificates. A real
|
||||||
|
NPM fixture with two distinct certificates reproduces failure with the old
|
||||||
|
configuration on the second hostname; the fixed fixture passes40 alternating
|
||||||
|
trusted TLS requests plus ACLs, WSS, certificate replacement, restart, failed-bind
|
||||||
|
rollback and disable/delete propagation.24 Python NPM regressions pass.
|
||||||
|
`/tmp/archy-190-npm-multicert-before.log` is the expected failing reproduction;
|
||||||
|
`/tmp/archy-190-npm-multicert-integration.log` is the fixed flat-layout pass.
|
||||||
|
Nested-layout issuance/renewal qualification is running separately.
|
||||||
|
|
||||||
|
The exact helper correction is temporarily installed on Shorty and transactional
|
||||||
|
sync succeeds.40 mixed local TLS requests across four hostnames pass. Public
|
||||||
|
read-only Angor browser acceptance now passes TLS, WSS, funding/event commitment,
|
||||||
|
Explore discovery of the known fixture and full project details/statistics:
|
||||||
|
`/tmp/archy-190-shorty-migrated-angor-browser-2.log`. This remains one known fixture,
|
||||||
|
not all35-project recovery.32 external IPv4 management-denial checks and tailnet
|
||||||
|
access pass;10 HTTP/HTTPS ACME routes return the exact probe written in NPM's data
|
||||||
|
mount. Six NPM database tables and42 certificate/renewal files exactly match the
|
||||||
|
pre-migration backup (`/tmp/archy-190-shorty-state-preservation.log`).
|
||||||
|
|
||||||
|
The previously running optimized build was stopped because it predates this
|
||||||
|
embedded-helper correction. A complete new build/deployment remains mandatory.
|
||||||
|
Shorty currently has the prior A5 candidate backend plus protected runtime nginx
|
||||||
|
and this qualified helper; an A5 restart can reinstall its older helper. Do not
|
||||||
|
claim final persistence until the new binary is deployed and restart is retested.
|
||||||
|
No certificate verification was disabled for a public application or upstream.
|
||||||
|
Raw-IP/unknown-SNI negative routing probes alone bypass hostname matching.
|
||||||
|
|
||||||
|
### NPM final source qualification and demo packaging
|
||||||
|
|
||||||
|
Backend source e0b2181a: all1,681 isolated tests pass (zero failures, four
|
||||||
|
explicit ignores). Corrected nested-layout NPM integration also passes real
|
||||||
|
local ACME issuance/renewal,40 cross-certificate TLS requests, WSS, ACLs,
|
||||||
|
restart, failed-bind rollback and host enable/delete propagation. Real public
|
||||||
|
Let’s Encrypt staging issuance plus forced renewal pass on migrated Shorty;
|
||||||
|
production certificate files and NPM container identity/start time are unchanged.
|
||||||
|
Evidence: `/tmp/archy-190-npm-guard-final-backend-tests.log`,
|
||||||
|
`/tmp/archy-190-npm-multicert-nested-acme.log`,
|
||||||
|
`/tmp/archy-190-shorty-migrated-staging-acme.log`.
|
||||||
|
Optimized build and final deployment/restart acceptance are still pending.
|
||||||
|
|
||||||
|
Demo image preparation found the web Dockerfile copied historical prebuilt AIUI.
|
||||||
|
It now builds the current source with the canonical script and frozen lockfile,
|
||||||
|
with explicit source revision for archive/container builds. Both CI workflows
|
||||||
|
track AIUI changes and pass the checkout revision. Actual image qualification
|
||||||
|
and public demo deployment remain pending. The demo/release VPS currently has
|
||||||
|
under1GiB free disk; capacity must be resolved before image/artifact publication.
|
||||||
|
No running container, volume, release or repository was deleted.
|
||||||
|
|
||||||
|
### Authorized release-storage cleanup — 2026-10-05
|
||||||
|
|
||||||
|
Operator approved removing only the old v1.8.13-alpha and v1.8.15-alpha ISO
|
||||||
|
attachments, then clarified that broader retention changes should be dropped if
|
||||||
|
that suffices. Both local ISO archives were independently hashed against their
|
||||||
|
published checksum files before removal. Gitea API deletion removed attachment
|
||||||
|
IDs219 and226 only; all other assets in both releases were verified unchanged.
|
||||||
|
Public server free space increased from887MiB to5.9GiB. Remaining historical
|
||||||
|
releases, OTA files, registry packages and application data were preserved.
|
||||||
|
Gitea's prior read-only storage doctor found no orphaned archives, attachments or
|
||||||
|
package blobs. No storage-doctor fix or package garbage collection was run.
|
||||||
|
Further removals are not planned; check exact final upload/image sizes first.
|
||||||
|
|
||||||
|
### Corrected binary deployed and restart verified
|
||||||
|
|
||||||
|
Final security backend SHA256
|
||||||
|
`560aa6006cd9ef8be95b1f7831cf3b53854e911622b50022bb4402ce0f8b010a`
|
||||||
|
built from e0b2181a after the complete1,681-test isolated pass. Deployed dev,
|
||||||
|
yaya and Shorty: health200, both embedded helper files match reviewed source,
|
||||||
|
active HTTP/HTTPS defaults are guarded, and all existing container IDs/start
|
||||||
|
times are unchanged. Backup per node: `support/190-guard-560aa6006cd9`.
|
||||||
|
The first dev check incorrectly inspected sites-available rather than the actual
|
||||||
|
regular sites-enabled file; automatic backend rollback ran. Corrected check
|
||||||
|
uses the helper's active-dashboard resolution, and the second deployment passes.
|
||||||
|
This was a qualification-script path error; retain the first failed log.
|
||||||
|
|
||||||
|
Shorty's final backend manager restart passed302 continuous public HTTP/HTTPS
|
||||||
|
RPC denial probes, followed by healthy management. Existing public32-case and
|
||||||
|
read-only Angor acceptance are rerunning against this exact deployed binary.
|
||||||
|
Logs: `/tmp/archy-190-guard-dev-deploy-2.log`,
|
||||||
|
`/tmp/archy-190-guard-yaya-deploy.log`, `/tmp/archy-190-guard-shorty-deploy.log`,
|
||||||
|
`/tmp/archy-190-final-shorty-restart-security.log`.
|
||||||
|
|
||||||
|
ISO release packaging now explicitly selects the qualified dashboard/AIUI
|
||||||
|
payload rather than capturing a live node's cached runtime files or choosing
|
||||||
|
AIUI by timestamp. Three executable builder-branch fixtures pass qualified,
|
||||||
|
missing and partial payloads; missing inputs fail without a live-node fallback.
|
||||||
|
The release wrapper sets this path and the release harness includes the test.
|
||||||
|
Accepted companion54 APK/metadata replace the stale copies in the packaging
|
||||||
|
staging directory; exact SHA remains ceb58a7d…fab1a1. Final ISO and OTA package
|
||||||
|
acceptance/signatures remain pending.
|
||||||
|
|
||||||
|
### Final demo images and exact-node follow-up
|
||||||
|
|
||||||
|
Shorty final backend restart follow-up passes the external32-case management
|
||||||
|
denial matrix and trusted public TLS/WSS/official Angor browser fixture. Evidence:
|
||||||
|
`/tmp/archy-190-final-shorty-public-security-after-restart.log` and
|
||||||
|
`/tmp/archy-190-final-shorty-angor-browser.log`. This is the known recovered
|
||||||
|
project, not all35-project discovery. Signed catalog479f6193 is privately active
|
||||||
|
on dev, yaya and Shorty; no public catalog publication has occurred.
|
||||||
|
|
||||||
|
Built demo images pass isolated real-image qualification: optional upstream DNS
|
||||||
|
failure returns502 for that service while the main demo remains200; fresh AIUI
|
||||||
|
provenance, backend healthcheck, four upload protocol/recovery cases, and normal
|
||||||
|
mobile intro/login/dashboard pass. The test uses a temporary container-only DNS
|
||||||
|
file; host DNS and the live public demo are untouched. Test:
|
||||||
|
`tests/lifecycle/demo-images.py`; evidence:
|
||||||
|
`/tmp/archy-190-demo-images-acceptance-final-2.log`. Earlier failure in the added
|
||||||
|
DNS test was an incorrect assumption about Podman's generated resolver, repaired
|
||||||
|
by explicitly mounting the disposable resolver fixture.
|
||||||
|
|
||||||
|
Qualified web image169e59da is built from157c9ec0; backend2722fa29 frome6e46a14.
|
||||||
|
Public demo deployment remains scheduled after release with rollback. RC2 raw
|
||||||
|
ISO assembly is running; no boot/install or final OTA pass is claimed yet.
|
||||||
|
|
||||||
|
### RC2 actual installation and first-boot retry correction
|
||||||
|
|
||||||
|
RC2 passed mounted payload checks and completed a full UEFI installation to an
|
||||||
|
80GiB disposable NVMe disk with encrypted data. Installed backend560, both
|
||||||
|
security helpers, dashboardc18, AIUI415 and APK54 match qualified bytes. After
|
||||||
|
boot from the installed disk, SSH, dashboard200 and backend health/version pass.
|
||||||
|
Actual installed nginx passes32 IPv4/IPv6 public-source denial requests including
|
||||||
|
unknown Host/SNI and forged forwarding headers (`/tmp/archy-190-vm-guard-test.log`).
|
||||||
|
The VM's EDAC hardware initialization service reports unsupported virtual
|
||||||
|
hardware; this is not claimed as physical ECC/EDAC acceptance.
|
||||||
|
|
||||||
|
Actual first boot exposed `log: command not found` in the unbundled setup: the
|
||||||
|
logger was declared below that path's early exit. Moving it before first use
|
||||||
|
restores diagnostics. Retry testing also demonstrated that unconditional
|
||||||
|
`podman system migrate` stops existing apps and races manager reconciliation.
|
||||||
|
The unbundled path changes no ID mappings and no longer migrates; bundled setup
|
||||||
|
only migrates when it actually adds mappings. Podman documents this stop behavior
|
||||||
|
in its [migration reference](https://docs.podman.io/en/latest/markdown/podman-system-migrate.1.html).
|
||||||
|
|
||||||
|
Corrected actual-VM retry preserves container IDs/start times and produces clean
|
||||||
|
logs: `/tmp/archy-190-vm-firstboot-logging-fix-2.log`. Executable isolated retry
|
||||||
|
regression passes twice with stored-secret preservation and fails against the
|
||||||
|
prior script. Added to release harness. RC2 must not be published: rebuild the
|
||||||
|
ISO with this script and qualify its boot/install path before signing. The OTA
|
||||||
|
backend/frontend payloads are unchanged by this installer-only correction.
|
||||||
|
|
||||||
|
Demo archive33ec4111…6fae8 matches after private server transfer; both tested image
|
||||||
|
IDs loaded successfully without changing running demo containers. Clearing only
|
||||||
|
unused build cache recovered1.743GB; no additional historical ISO, image, volume
|
||||||
|
or application data was deleted. Final publication capacity must be rechecked.
|
||||||
|
|
||||||
|
### Operator accepts Angor discovery limitation — 2026-10-05
|
||||||
|
|
||||||
|
The operator explicitly accepted releasing with incomplete historical project
|
||||||
|
discovery documented as a known limitation ("that's fine for now"). Funding
|
||||||
|
commitments for all35 reference projects were verified;34 original signed
|
||||||
|
announcements remain unrecovered from the sources checked. This releases the
|
||||||
|
all-project-discovery publication hold, not a claim that recovery passed. Track
|
||||||
|
recovery separately and retain the limitation in release notes. Other artifact,
|
||||||
|
upgrade, security and publication checks remain required.
|
||||||
|
|
||||||
|
### Work explicitly queued after this release
|
||||||
|
|
||||||
|
The operator requested returning to distributed IndeeHub, signer/companion login,
|
||||||
|
node peering, Framework Monitoring and external-app Cloud integration after
|
||||||
|
1.9.0-alpha. All details are retained in
|
||||||
|
[the post-release backlog](post-1.9.0-work-backlog.md). These additions do not
|
||||||
|
expand or delay the current release's artifact scope.
|
||||||
|
|
||||||
|
### Final RC3 installed-artifact qualification — 2026-10-05
|
||||||
|
|
||||||
|
Final raw ISO `archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso` is byte-for-byte
|
||||||
|
the tested RC3 (SHA256 `aad5f0350428976969043079a63b0e7a8264dcee2574526bd34719c798c750af`).
|
||||||
|
Actual installation completed to a disposable80GiB disk with encrypted data.
|
||||||
|
Installed legacy BIOS/SATA and UEFI/SATA boot, mounted encrypted data, healthy
|
||||||
|
backend1.9.0-alpha with completed startup recovery, and private dashboard pass.
|
||||||
|
The initial SeaBIOS/NVMe boot could not find the disk; the same installed disk
|
||||||
|
boots with SATA. Installer logs show both GRUB installations succeeded. This
|
||||||
|
is not a claim of physical legacy-BIOS/NVMe support.
|
||||||
|
|
||||||
|
The final installed script retries without changing container IDs/start times
|
||||||
|
or managed File Browser credentials. Normal dashboard setup/login, Cloud token
|
||||||
|
and authenticated listing pass; anonymous and admin/admin access are rejected.
|
||||||
|
Initial test omitted CSRF and correctly received403; corrected normal-cookie/
|
||||||
|
CSRF flow passes. Onboarding also replaces the initial SSH password as expected.
|
||||||
|
The fixture was corrected to retain its own generated password privately; no
|
||||||
|
production credentials or access controls were changed.
|
||||||
|
|
||||||
|
After UEFI boot the installed nginx passes32 IPv4/IPv6 public-source rejection
|
||||||
|
cases including unknown Host/SNI, forged forwarding headers, assets, RPC and
|
||||||
|
WebSocket upgrade requests. Early health showed startup recovery still running;
|
||||||
|
subsequent explicit status/recovery assertions pass after61seconds.
|
||||||
|
Evidence: `/tmp/archy-190-rc3-installed-test-2.log`,
|
||||||
|
`/tmp/archy-190-rc3-uefi-acceptance-2.log`,
|
||||||
|
`/tmp/archy-190-rc3-uefi-healthy.log`. Unsupported virtual EDAC remains separate
|
||||||
|
from the supported service checks.
|
||||||
|
|
||||||
|
Artifacts are ready for operator signing, not publication. The final signed
|
||||||
|
OTA apply/rollback test necessarily follows signing. Mirror/tag parity, public
|
||||||
|
artifact downloads, catalog promotion, fleet discovery and demo deployment
|
||||||
|
remain required. The Angor historical limitation is explicitly accepted and
|
||||||
|
documented in [known limitations](release-1.9.0-known-limitations.md).
|
||||||
|
|
||||||
|
### Signed OTA qualification — 2026-10-05
|
||||||
|
|
||||||
|
Operator signed final OTA manifest and raw-ISO checksum document; both verify
|
||||||
|
against the pinned release root. Existing signed catalog also verifies. On the
|
||||||
|
disposable installed VM, the actual published1.8.22 backend and frontend were
|
||||||
|
verified against their published hashes, installed as the baseline, and used to
|
||||||
|
discover/download/apply the final signed1.9.0 artifacts through normal authenticated
|
||||||
|
RPC. Component verification, automatic manager restart, post-OTA verification,
|
||||||
|
exact backend/UI hashes, Cloud access and persistent credential/file checks pass.
|
||||||
|
|
||||||
|
A deliberately missing new frontend plus the real pending-verification marker
|
||||||
|
triggered automatic rollback: exact1.8.22 binary/UI restored, file and credentials
|
||||||
|
preserved.32 public-source IPv4/IPv6 management-denial requests still pass with
|
||||||
|
the restored old binary/template. Reapplying the signed1.9.0 OTA succeeds with the
|
||||||
|
same post-update assertions. Final whole-VM reboot qualification is running.
|
||||||
|
Logs: `/tmp/archy-190-vm-ota-cycle-2.log`,
|
||||||
|
`/tmp/archy-190-vm-ota-rollback.log`,
|
||||||
|
`/tmp/archy-190-vm-ota-rollback-security.log`,
|
||||||
|
`/tmp/archy-190-vm-ota-reapply.log`. Fixture setup corrections (missing systemd
|
||||||
|
drop-in directory and underscore in update_state.json) preceded the passing run;
|
||||||
|
no failed fixture run is counted as acceptance.
|
||||||
|
|
||||||
|
Publication storage required temporary upload headroom beyond the previous
|
||||||
|
cleanup. Under the operator's existing old-ISO retention authorization, removed
|
||||||
|
only1.8.18 ISO attachment235 after verifying its retained local copy against the
|
||||||
|
public signed checksum. All other assets unchanged;1.8.19/21/22 server ISOs remain.
|
||||||
|
Server free space is7.2GB before upload. Use an SSH-tunneled direct Gitea upload
|
||||||
|
so the public reverse proxy does not buffer an additional multi-GB copy.
|
||||||
|
|
||||||
|
Historical mirror audit identified three missing ngit tags1.8.16/17/18; their
|
||||||
|
local annotated tag objects exactly matched Gitea and were copied to ngit without
|
||||||
|
rewriting history. Unrelated proposal-only branch differences are inventoried
|
||||||
|
in `/tmp/archy-190-historical-mirror-audit.log`; full branch parity is not claimed.
|
||||||
|
Final main/tag parity remains a separate publication gate.
|
||||||
|
|
||||||
|
### Final reboot caught a stale OTA runtime script — corrected package
|
||||||
|
|
||||||
|
The whole-VM reboot itself reached healthy1.9.0, but the first-boot retry check
|
||||||
|
failed: the OTA runtime overlay still shipped the old first-boot script and
|
||||||
|
bootstrap installed it over the ISO's corrected version. Retry logged missing
|
||||||
|
`log` and changed running container IDs/start times. This is a real package
|
||||||
|
regression, not a passed check. The original signed frontend archive3047a19f is
|
||||||
|
obsolete and MUST NOT be published.
|
||||||
|
|
||||||
|
Repacked only `archipelago-runtime/scripts/first-boot-containers.sh` with the
|
||||||
|
already qualified source repair. Full archive comparison proves every other
|
||||||
|
entry, content and mode unchanged. Corrected frontend SHA256 is
|
||||||
|
`6d5135fa8e79b1bc84c8ed972770ebf99fe6611d819e5c1453f38f1593c26e66`.
|
||||||
|
ISO/backend/dashboard/AIUI/APK/catalog bytes are unchanged; ISO and catalog
|
||||||
|
signatures remain valid. Only the corrected OTA manifest needs renewed signing.
|
||||||
|
|
||||||
|
Added a release-manifest payload gate that rejects stale, absent or duplicate
|
||||||
|
first-boot scripts. Four archive fixture cases and existing executable first-boot
|
||||||
|
retry regression pass; the stale real archive fails, corrected real archive
|
||||||
|
passes. Actual backend bootstrap successfully promotes the corrected member on
|
||||||
|
the disposable node. Post-promotion retry/Cloud checks are in progress.
|
||||||
|
Logs: `/tmp/archy-190-stale-ota-reproduced.log`,
|
||||||
|
`/tmp/archy-190-repackage-runtime-2.log`,
|
||||||
|
`/tmp/archy-190-corrected-manifest-integrity.log`,
|
||||||
|
`/tmp/archy-190-vm-corrected-runtime.log`,
|
||||||
|
`/tmp/archy-190-vm-corrected-retry.log`. Renewed signature and exact signed apply
|
||||||
|
remain required. Keep earlier rollback evidence for the unchanged backend, but
|
||||||
|
do not claim the obsolete frontend is the final accepted artifact.
|
||||||
|
|
||||||
|
Corrected runtime post-promotion retry now PASS: container IDs/start times and
|
||||||
|
credentials preserved, Cloud token/listing work, default/anonymous access denied.
|
||||||
|
Corrected OTA pre-sign receipt is ready; existing ISO/catalog signatures retained.
|
||||||
|
|
||||||
|
### Corrected signed OTA final reboot — PASS
|
||||||
|
|
||||||
|
The renewed OTA signature verifies. Published1.8.22 baseline discovered, downloaded
|
||||||
|
and applied corrected frontend6d5135fa plus backend560aa600 through normal RPC.
|
||||||
|
Exact payload hashes, automatic restart/verification, saved file/credentials and
|
||||||
|
Cloud login pass. A full VM reboot (boot time2026-10-05T22:52:47Z) then passed
|
||||||
|
healthy startup, actual first-boot retry with unchanged container IDs/start times
|
||||||
|
and credentials, managed Cloud access, default/anonymous login denial and32
|
||||||
|
IPv4/IPv6 management-denial requests. Logs:
|
||||||
|
`/tmp/archy-190-vm-corrected-signed-ota.log`,
|
||||||
|
`/tmp/archy-190-vm-corrected-signed-reboot.log`.
|
||||||
|
|
||||||
|
Installed and cached runtime first-boot scripts on dev, yaya and Shorty now match
|
||||||
|
the qualified source, with private backups and no app/service operation:
|
||||||
|
`/tmp/archy-190-firstboot-final-node-deploy.log`. Existing ISO/catalog signatures
|
||||||
|
remain valid. The obsolete frontend must remain archived only.
|
||||||
|
|
||||||
|
Source/tag parity, asset publication/public hashes, public catalog selection,
|
||||||
|
fleet discovery and demo deployment are the remaining release operations.
|
||||||
|
Retain coverage boundaries: virtual BIOS/SATA and UEFI/SATA tested, no physical
|
||||||
|
legacy-BIOS/NVMe claim; IPv6 guard tested in isolation, no actual WAN IPv6 route;
|
||||||
|
Framework authenticated read-only fee quote and a new live seller-outage exercise
|
||||||
|
were not independently completed (funded regtest, prior operator purchase/free
|
||||||
|
reopen and persisted ownership/file evidence remain separate). Dev Bitcoin is
|
||||||
|
still in IBD; live full-chain Angor evidence is from Shorty. Historical discovery
|
||||||
|
limitation was explicitly accepted, not relabeled a passing recovery test.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 1.9.0-alpha: Angor historical discovery
|
||||||
|
|
||||||
|
Historical project discovery is incomplete. Funding commitments for all35
|
||||||
|
reference projects were verified, but34 original signed announcements were not
|
||||||
|
recovered from the relays and archives checked. They are not proven globally
|
||||||
|
lost. One recovered project passes Explore, details, statistics and public
|
||||||
|
TLS/WebSocket acceptance using the self-hosted services.
|
||||||
|
|
||||||
|
The operator explicitly accepted shipping with this limitation on2026-10-05.
|
||||||
|
Recovery remains follow-up work; this acceptance does not mean all-project
|
||||||
|
discovery passed. Retain the original relays alongside the self-hosted relay.
|
||||||
|
|
||||||
|
Full indexing requires a synced, unpruned Bitcoin node and indexing dependencies.
|
||||||
|
The dev node is still syncing; full-chain evidence comes from Shorty.
|
||||||
|
|
||||||
|
Evidence and inventory: [client acceptance](angor-client-acceptance-20261001.md),
|
||||||
|
[project recovery inventory](angor-project-recovery-20261001.json), and
|
||||||
|
[release acceptance](release-1.9.0-acceptance.md).
|
||||||
@@ -1343,6 +1343,15 @@ else
|
|||||||
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
|
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Mandatory offline flasher: cached rootfs images may lack system esptool.
|
||||||
|
ESPTOOL_BUNDLE="${ARCHY_ESPTOOL:-$SCRIPT_DIR/../../reticulum-daemon/dist/archy-esptool}"
|
||||||
|
if [ ! -x "$ESPTOOL_BUNDLE" ]; then
|
||||||
|
echo "ERROR: packaged archy-esptool missing; build reticulum-daemon/build-esptool.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$ESPTOOL_BUNDLE" --archy-self-test || exit 1
|
||||||
|
install -m 755 "$ESPTOOL_BUNDLE" "$ARCH_DIR/bin/archy-esptool"
|
||||||
|
|
||||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
echo " ⚠️ Could not capture from live server, building from source..."
|
echo " ⚠️ Could not capture from live server, building from source..."
|
||||||
@@ -1439,7 +1448,19 @@ mkdir -p "$ARCH_DIR/web-ui"
|
|||||||
|
|
||||||
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
||||||
WEBUI_CAPTURED=0
|
WEBUI_CAPTURED=0
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ -n "${ARCHIPELAGO_WEB_UI:-}" ]; then
|
||||||
|
# Release builds supply the qualified payload explicitly. Never substitute
|
||||||
|
# a running node's cached runtime files or an older installed dashboard.
|
||||||
|
if [ ! -f "$ARCHIPELAGO_WEB_UI/index.html" ] || \
|
||||||
|
[ ! -f "$ARCHIPELAGO_WEB_UI/aiui/index.html" ] || \
|
||||||
|
[ ! -f "$ARCHIPELAGO_WEB_UI/aiui/BUILD-INFO" ]; then
|
||||||
|
echo "ERROR: ARCHIPELAGO_WEB_UI must contain the qualified dashboard and AIUI"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -r "$ARCHIPELAGO_WEB_UI/." "$ARCH_DIR/web-ui/"
|
||||||
|
WEBUI_CAPTURED=1
|
||||||
|
fi
|
||||||
|
if [ "$WEBUI_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
# Direct copy from local filesystem (when running on target with sudo)
|
# Direct copy from local filesystem (when running on target with sudo)
|
||||||
if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then
|
if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then
|
||||||
cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/"
|
cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/"
|
||||||
@@ -1495,6 +1516,9 @@ AIUI_INCLUDED=0
|
|||||||
# or yesterday's hashed assets linger next to today's forever.
|
# or yesterday's hashed assets linger next to today's forever.
|
||||||
AIUI_SRC=""
|
AIUI_SRC=""
|
||||||
AIUI_NEWEST=0
|
AIUI_NEWEST=0
|
||||||
|
if [ -n "${ARCHIPELAGO_WEB_UI:-}" ]; then
|
||||||
|
AIUI_SRC="$ARCHIPELAGO_WEB_UI/aiui"
|
||||||
|
else
|
||||||
for AIUI_DIR in \
|
for AIUI_DIR in \
|
||||||
"$SCRIPT_DIR/../../aiui/packages/app/dist" \
|
"$SCRIPT_DIR/../../aiui/packages/app/dist" \
|
||||||
"$SCRIPT_DIR/../../AIUI/packages/app/dist" \
|
"$SCRIPT_DIR/../../AIUI/packages/app/dist" \
|
||||||
@@ -1511,6 +1535,7 @@ for AIUI_DIR in \
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
fi
|
||||||
if [ -n "$AIUI_SRC" ]; then
|
if [ -n "$AIUI_SRC" ]; then
|
||||||
echo " Including AIUI from $AIUI_SRC (newest of the candidates)..."
|
echo " Including AIUI from $AIUI_SRC (newest of the candidates)..."
|
||||||
mkdir -p "$ARCH_DIR/web-ui/aiui"
|
mkdir -p "$ARCH_DIR/web-ui/aiui"
|
||||||
@@ -2449,42 +2474,24 @@ runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && syst
|
|||||||
# Ensure podman socket is active for archipelago user
|
# Ensure podman socket is active for archipelago user
|
||||||
runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && systemctl --user enable --now podman.socket' 2>>"$LOG" || true
|
runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && systemctl --user enable --now podman.socket' 2>>"$LOG" || true
|
||||||
|
|
||||||
# Create FileBrowser container as archipelago user (rootless podman)
|
# Provision the same unique verified Cloud login used by app installation/OTA.
|
||||||
# Generate random FileBrowser password and store for auto-login
|
if ! runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman container exists filebrowser; then
|
||||||
FB_PASS_DIR="/var/lib/archipelago/secrets/filebrowser"
|
install -d -o 100000 -g 100000 /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
|
||||||
mkdir -p "$FB_PASS_DIR"
|
install -d -o archipelago -g archipelago -m 700 /var/lib/archipelago/secrets/filebrowser
|
||||||
if [ ! -f "$FB_PASS_DIR/password" ]; then
|
runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 \
|
||||||
head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24 > "$FB_PASS_DIR/password"
|
python3 /opt/archipelago/scripts/filebrowser-credentials.py --image "$FILEBROWSER_IMAGE" >>"$LOG" 2>&1 || exit 1
|
||||||
chmod 600 "$FB_PASS_DIR/password"
|
runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman run -d \
|
||||||
chown 1000:1000 "$FB_PASS_DIR/password"
|
--name filebrowser --restart unless-stopped \
|
||||||
fi
|
--cap-drop=ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE \
|
||||||
|
|
||||||
if ! runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman ps -a --format "{{.Names}}"' 2>/dev/null | grep -q filebrowser; then
|
|
||||||
echo "[$(date)] Creating FileBrowser container ($FILEBROWSER_IMAGE)..." >> "$LOG"
|
|
||||||
runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman run -d --name filebrowser --restart unless-stopped \
|
|
||||||
--cap-drop=ALL \
|
|
||||||
--cap-add=DAC_OVERRIDE \
|
|
||||||
--cap-add=NET_BIND_SERVICE \
|
|
||||||
--security-opt=no-new-privileges:true \
|
--security-opt=no-new-privileges:true \
|
||||||
--read-only \
|
|
||||||
--tmpfs=/tmp:rw,noexec,nosuid,size=64m \
|
--tmpfs=/tmp:rw,noexec,nosuid,size=64m \
|
||||||
--health-cmd='curl -sf http://localhost:80/ || exit 1' \
|
--health-cmd='wget -q --spider http://localhost:80/health || exit 1' \
|
||||||
--health-interval=30s --health-timeout=5s --health-retries=3 \
|
--health-interval=30s --health-timeout=5s --health-retries=3 \
|
||||||
--memory=256m \
|
--memory=256m -p 127.0.0.1:8083:80 \
|
||||||
-p 8083:80 \
|
|
||||||
-v /var/lib/archipelago/filebrowser:/srv \
|
-v /var/lib/archipelago/filebrowser:/srv \
|
||||||
-v /var/lib/archipelago/filebrowser-data:/data \
|
-v /var/lib/archipelago/filebrowser-data:/data \
|
||||||
-v /var/lib/archipelago/data/cloud:/srv/cloud \
|
-v /var/lib/archipelago/data/cloud:/srv/cloud \
|
||||||
$FILEBROWSER_IMAGE \
|
"$FILEBROWSER_IMAGE" --config /data/.filebrowser.json >>"$LOG" 2>&1 || exit 1
|
||||||
--database=/data/database.db --root=/srv --address=0.0.0.0 --port=80" 2>>"$LOG" && \
|
|
||||||
echo "[$(date)] FileBrowser created successfully" >> "$LOG" || \
|
|
||||||
echo "[$(date)] WARNING: FileBrowser creation failed" >> "$LOG"
|
|
||||||
# Set FileBrowser password to match the stored random password
|
|
||||||
sleep 5
|
|
||||||
FB_PASS=$(cat "$FB_PASS_DIR/password" 2>/dev/null || echo "admin")
|
|
||||||
runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman exec filebrowser filebrowser users update admin --password '$FB_PASS' --database /data/database.db" 2>>"$LOG" && \
|
|
||||||
echo "[$(date)] FileBrowser admin password set" >> "$LOG" || \
|
|
||||||
echo "[$(date)] WARNING: Could not set FileBrowser password" >> "$LOG"
|
|
||||||
fi
|
fi
|
||||||
echo "[$(date)] Minimal first-boot complete" >> "$LOG"
|
echo "[$(date)] Minimal first-boot complete" >> "$LOG"
|
||||||
FBUNBUNDLED
|
FBUNBUNDLED
|
||||||
@@ -2611,6 +2618,12 @@ fi
|
|||||||
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
|
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
|
||||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
|
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
|
||||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
|
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
|
||||||
|
for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do
|
||||||
|
cp "$SCRIPT_DIR/../../scripts/$npm_file" "$ARCH_DIR/scripts/"
|
||||||
|
done
|
||||||
|
for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
|
||||||
|
cp "$SCRIPT_DIR/../configs/$npm_unit" "$ARCH_DIR/scripts/"
|
||||||
|
done
|
||||||
|
|
||||||
# Build-source apps need their complete contexts even on unbundled ISOs.
|
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||||
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||||
@@ -3142,6 +3155,10 @@ if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
|
|||||||
chmod +x /mnt/target/usr/local/bin/* 2>/dev/null || true
|
chmod +x /mnt/target/usr/local/bin/* 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Required even when the cached rootfs never had esptool installed.
|
||||||
|
install -m 755 "$BOOT_MEDIA/archipelago/bin/archy-esptool" /mnt/target/usr/local/bin/archy-esptool || exit 1
|
||||||
|
chroot /mnt/target /usr/local/bin/archy-esptool --archy-self-test || exit 1
|
||||||
|
|
||||||
if [ -d "$BOOT_MEDIA/archipelago/web-ui" ]; then
|
if [ -d "$BOOT_MEDIA/archipelago/web-ui" ]; then
|
||||||
cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/
|
cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/
|
||||||
fi
|
fi
|
||||||
@@ -3245,6 +3262,13 @@ done
|
|||||||
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
|
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||||
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
|
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
|
||||||
done
|
done
|
||||||
|
for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do
|
||||||
|
install -m 755 "$BOOT_MEDIA/archipelago/scripts/$npm_file" "/mnt/target/opt/archipelago/scripts/$npm_file" || exit 1
|
||||||
|
done
|
||||||
|
for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
|
||||||
|
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$npm_unit" "/mnt/target/etc/systemd/system/$npm_unit" || exit 1
|
||||||
|
done
|
||||||
|
systemctl --root=/mnt/target enable archipelago-npm-bridge.timer || exit 1
|
||||||
# END DOCTOR OVERLAY
|
# END DOCTOR OVERLAY
|
||||||
|
|
||||||
# Copy self-update script
|
# Copy self-update script
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
QEMU_TMPDIR="${TMPDIR:-/tmp}"
|
QEMU_TMPDIR="${TMPDIR:-/tmp}"
|
||||||
|
SSH_FORWARD_PORT="${QEMU_SSH_PORT:-2222}"
|
||||||
|
HTTP_FORWARD_PORT="${QEMU_HTTP_PORT:-8100}"
|
||||||
SERIAL_LOG="$QEMU_TMPDIR/archipelago-qemu-serial.log"
|
SERIAL_LOG="$QEMU_TMPDIR/archipelago-qemu-serial.log"
|
||||||
FORCE_BIOS=false
|
FORCE_BIOS=false
|
||||||
NOGRAPHIC=false
|
NOGRAPHIC=false
|
||||||
@@ -67,6 +69,10 @@ echo " CPU: 2 cores"
|
|||||||
echo " Serial: $SERIAL_LOG"
|
echo " Serial: $SERIAL_LOG"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Never accept boot markers left by an earlier VM.
|
||||||
|
mkdir -p "$QEMU_TMPDIR"
|
||||||
|
: > "$SERIAL_LOG"
|
||||||
|
|
||||||
# Create test disk if it doesn't exist
|
# Create test disk if it doesn't exist
|
||||||
DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2"
|
DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2"
|
||||||
if [ ! -f "$DISK" ]; then
|
if [ ! -f "$DISK" ]; then
|
||||||
@@ -81,8 +87,9 @@ QEMU_ARGS=(
|
|||||||
-boot d
|
-boot d
|
||||||
-cdrom "$ISO"
|
-cdrom "$ISO"
|
||||||
-drive if=virtio,format=qcow2,file="$DISK"
|
-drive if=virtio,format=qcow2,file="$DISK"
|
||||||
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:80
|
-net nic,model=virtio -net "user,hostfwd=tcp:127.0.0.1:${SSH_FORWARD_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_FORWARD_PORT}-:80"
|
||||||
-serial file:"$SERIAL_LOG"
|
-serial file:"$SERIAL_LOG"
|
||||||
|
-qmp "unix:$QEMU_TMPDIR/archipelago-qmp.sock,server=on,wait=off"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display mode
|
# Display mode
|
||||||
@@ -99,28 +106,37 @@ echo ""
|
|||||||
|
|
||||||
# Detect UEFI firmware
|
# Detect UEFI firmware
|
||||||
OVMF=""
|
OVMF=""
|
||||||
|
OVMF_VARS=""
|
||||||
if [ "$FORCE_BIOS" = false ]; then
|
if [ "$FORCE_BIOS" = false ]; then
|
||||||
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
|
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
|
||||||
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
|
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
|
||||||
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
|
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
|
||||||
OVMF="/usr/share/OVMF/OVMF_CODE.fd"
|
OVMF="/usr/share/OVMF/OVMF_CODE.fd"
|
||||||
|
OVMF_VARS="/usr/share/OVMF/OVMF_VARS.fd"
|
||||||
|
elif [ -f "/usr/share/OVMF/OVMF_CODE_4M.fd" ]; then
|
||||||
|
OVMF="/usr/share/OVMF/OVMF_CODE_4M.fd"
|
||||||
|
OVMF_VARS="/usr/share/OVMF/OVMF_VARS_4M.fd"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_qemu() {
|
QEMU_COMMAND=(qemu-system-x86_64)
|
||||||
if [ -n "$OVMF" ]; then
|
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
|
||||||
echo " Boot: UEFI ($OVMF)"
|
QEMU_COMMAND+=(-enable-kvm)
|
||||||
qemu-system-x86_64 \
|
fi
|
||||||
-machine q35 \
|
if [ -n "$OVMF" ]; then
|
||||||
-drive if=pflash,format=raw,readonly=on,file="$OVMF" \
|
echo " Boot: UEFI ($OVMF)"
|
||||||
"${QEMU_ARGS[@]}"
|
QEMU_COMMAND+=(-machine q35 -drive "if=pflash,format=raw,readonly=on,file=$OVMF")
|
||||||
else
|
if [ -f "$OVMF_VARS" ]; then
|
||||||
echo " Boot: Legacy BIOS"
|
if [ ! -f "$QEMU_TMPDIR/archipelago-uefi-vars.fd" ]; then
|
||||||
qemu-system-x86_64 \
|
cp "$OVMF_VARS" "$QEMU_TMPDIR/archipelago-uefi-vars.fd" || exit 1
|
||||||
-machine pc \
|
fi
|
||||||
"${QEMU_ARGS[@]}"
|
QEMU_COMMAND+=(-drive "if=pflash,format=raw,file=$QEMU_TMPDIR/archipelago-uefi-vars.fd")
|
||||||
fi
|
fi
|
||||||
}
|
else
|
||||||
|
echo " Boot: Legacy BIOS"
|
||||||
|
QEMU_COMMAND+=(-machine pc)
|
||||||
|
fi
|
||||||
|
QEMU_COMMAND+=("${QEMU_ARGS[@]}")
|
||||||
|
|
||||||
# Wrap the QEMU invocation in `timeout` when a CI caller passed one so
|
# Wrap the QEMU invocation in `timeout` when a CI caller passed one so
|
||||||
# the script always returns instead of hanging on a VM that never exits
|
# the script always returns instead of hanging on a VM that never exits
|
||||||
@@ -129,14 +145,16 @@ run_qemu() {
|
|||||||
# the serial log shows a kernel reaching userspace — we inspect that
|
# the serial log shows a kernel reaching userspace — we inspect that
|
||||||
# after the QEMU process ends.
|
# after the QEMU process ends.
|
||||||
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
|
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
|
||||||
timeout --foreground --preserve-status "${TIMEOUT}s" bash -c "$(declare -f run_qemu); run_qemu"
|
# A new bash -c loses the unexportable argument array and firmware path.
|
||||||
|
# Invoke the complete command directly and keep timeout's distinct status.
|
||||||
|
timeout --foreground --kill-after=10 "${TIMEOUT}s" "${QEMU_COMMAND[@]}"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ $rc -eq 124 ] || [ $rc -eq 137 ]; then
|
if [ $rc -eq 124 ]; then
|
||||||
echo "(QEMU terminated after ${TIMEOUT}s boot-test window)"
|
echo "(QEMU terminated after ${TIMEOUT}s boot-test window)"
|
||||||
rc=0
|
rc=0
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
run_qemu
|
"${QEMU_COMMAND[@]}"
|
||||||
rc=$?
|
rc=$?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -150,12 +168,15 @@ tail -20 "$SERIAL_LOG" 2>/dev/null
|
|||||||
# by live-boot/systemd early in the sequence. If the marker never
|
# by live-boot/systemd early in the sequence. If the marker never
|
||||||
# appeared, surface the real failure; otherwise treat "timeout reached
|
# appeared, surface the real failure; otherwise treat "timeout reached
|
||||||
# with a live kernel" as a pass.
|
# with a live kernel" as a pass.
|
||||||
|
if [ "${rc:-0}" -ne 0 ]; then
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null && [ -f "$SERIAL_LOG" ]; then
|
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null && [ -f "$SERIAL_LOG" ]; then
|
||||||
if grep -qE "Welcome to Debian|Reached target|systemd\[1\]:" "$SERIAL_LOG"; then
|
if grep -qE 'Welcome to Debian|Reached target|systemd\[1\]:|Debian GNU/Linux [0-9]+ archipelago-installer ttyS0' "$SERIAL_LOG"; then
|
||||||
echo " Boot sanity: OK (systemd reached in serial log)"
|
echo " Boot sanity: OK (userspace reached in serial log; installation not yet tested)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo " Boot sanity: FAIL — no systemd markers in serial log within ${TIMEOUT}s"
|
echo " Boot sanity: FAIL — no userspace markers in serial log within ${TIMEOUT}s"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
exit "${rc:-0}"
|
exit "${rc:-0}"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Synchronize NPM public domains and certificate renewal
|
||||||
|
After=nginx.service archipelago.service
|
||||||
|
ConditionPathExists=/etc/nginx/sites-available/archipelago
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/dashboard-public-guard.py
|
||||||
|
ExecStart=/usr/bin/python3 /opt/archipelago/scripts/npm-public-bridge.py
|
||||||
|
TimeoutStartSec=120
|
||||||
|
UMask=0077
|
||||||
|
Nice=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Watch NPM domain configuration and renewed certificates
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30s
|
||||||
|
OnUnitInactiveSec=15s
|
||||||
|
AccuracySec=1s
|
||||||
|
Unit=archipelago-npm-bridge.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
|
# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||||
|
# Use the original socket peer, before any real_ip / forwarded-header rewrite.
|
||||||
|
geo $realip_remote_addr $archy_management_private_source {
|
||||||
|
default 0;
|
||||||
|
127.0.0.0/8 1;
|
||||||
|
169.254.0.0/16 1;
|
||||||
|
10.0.0.0/8 1;
|
||||||
|
172.16.0.0/12 1;
|
||||||
|
192.168.0.0/16 1;
|
||||||
|
100.64.0.0/10 1;
|
||||||
|
::1/128 1;
|
||||||
|
fc00::/7 1;
|
||||||
|
fe80::/10 1;
|
||||||
|
}
|
||||||
|
# A configured trusted proxy may have rewritten remote_addr. Require both
|
||||||
|
# the original peer and the validated effective client to be private.
|
||||||
|
geo $remote_addr $archy_management_private_client {
|
||||||
|
default 0;
|
||||||
|
127.0.0.0/8 1;
|
||||||
|
169.254.0.0/16 1;
|
||||||
|
10.0.0.0/8 1;
|
||||||
|
172.16.0.0/12 1;
|
||||||
|
192.168.0.0/16 1;
|
||||||
|
100.64.0.0/10 1;
|
||||||
|
::1/128 1;
|
||||||
|
fc00::/7 1;
|
||||||
|
fe80::/10 1;
|
||||||
|
}
|
||||||
|
map $http_x_archipelago_public_ingress $archy_management_public_ingress {
|
||||||
|
default 1;
|
||||||
|
'' 0;
|
||||||
|
}
|
||||||
|
map "$archy_management_private_source:$archy_management_private_client:$archy_management_public_ingress:$uri" $archy_management_denied {
|
||||||
|
default 1;
|
||||||
|
~^1:1:0: 0;
|
||||||
|
"~^[01]:[01]:[01]:/\.well-known/acme-challenge/[A-Za-z0-9_-]+$" 0;
|
||||||
|
}
|
||||||
|
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||||
|
|
||||||
# Rate limit zones
|
# Rate limit zones
|
||||||
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
||||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
||||||
@@ -8,6 +47,8 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
|
|||||||
resolver_timeout 5s;
|
resolver_timeout 5s;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
if ($archy_management_denied) { return 404; }
|
||||||
|
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
# IPv6 listener is REQUIRED: companion phones reach this node over the
|
# IPv6 listener is REQUIRED: companion phones reach this node over the
|
||||||
# FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that
|
# FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that
|
||||||
@@ -48,7 +89,7 @@ server {
|
|||||||
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
|
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
default_type text/plain;
|
default_type text/plain;
|
||||||
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
|
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1062,8 @@ server {
|
|||||||
|
|
||||||
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
||||||
server {
|
server {
|
||||||
|
if ($archy_management_denied) { return 404; }
|
||||||
|
|
||||||
listen 443 ssl default_server;
|
listen 443 ssl default_server;
|
||||||
listen [::]:443 ssl default_server;
|
listen [::]:443 ssl default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -1035,6 +1078,12 @@ server {
|
|||||||
include snippets/archipelago-pwa.conf;
|
include snippets/archipelago-pwa.conf;
|
||||||
|
|
||||||
# Same CA download over HTTPS — see the note in the HTTP block above.
|
# Same CA download over HTTPS — see the note in the HTTP block above.
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
default_type text/plain;
|
||||||
|
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location = /ca.crt {
|
location = /ca.crt {
|
||||||
alias /etc/archipelago/ssl/ca-download.crt;
|
alias /etc/archipelago/ssl/ca-download.crt;
|
||||||
default_type application/x-x509-ca-cert;
|
default_type application/x-x509-ca-cert;
|
||||||
|
|||||||
+13
-2
@@ -1,3 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS aiui-builder
|
||||||
|
RUN apk add --no-cache bash git coreutils findutils && corepack enable
|
||||||
|
WORKDIR /source
|
||||||
|
COPY aiui/ ./aiui/
|
||||||
|
COPY scripts/build-aiui.sh ./scripts/build-aiui.sh
|
||||||
|
ARG SOURCE_REVISION
|
||||||
|
ARG VITE_DEMO=1
|
||||||
|
RUN test -n "$SOURCE_REVISION" && \
|
||||||
|
if [ "$VITE_DEMO" = "1" ] || [ "$VITE_DEMO" = "true" ]; then export VITE_DEMO_CONTENT=true; fi && \
|
||||||
|
ARCHY_SOURCE_REVISION="$SOURCE_REVISION" bash scripts/build-aiui.sh
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -49,8 +60,8 @@ FROM nginx:alpine
|
|||||||
# Copy built files to nginx
|
# Copy built files to nginx
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy AIUI pre-built dist
|
# Build AIUI from the same source revision as the dashboard.
|
||||||
COPY demo/aiui/ /usr/share/nginx/html/aiui/
|
COPY --from=aiui-builder /source/aiui/packages/app/dist/ /usr/share/nginx/html/aiui/
|
||||||
|
|
||||||
# Copy nginx config template and entrypoint
|
# Copy nginx config template and entrypoint
|
||||||
COPY neode-ui/docker/nginx-demo.conf /etc/nginx/nginx.conf.template
|
COPY neode-ui/docker/nginx-demo.conf /etc/nginx/nginx.conf.template
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// In-memory File Browser TUS subset used by the public demo. Each visitor's
|
||||||
|
// reservations and bytes belong to their existing isolated, expiring session.
|
||||||
|
export function installDemoUploads(app, { currentStore, quota, normalize, parent, base, type }) {
|
||||||
|
const route = '/app/filebrowser/api/tus/*'
|
||||||
|
const number = value => typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null
|
||||||
|
const locate = req => normalize(req.params[0] || '')
|
||||||
|
const headers = (res, upload) => res.set({
|
||||||
|
'Tus-Resumable': '1.0.0', 'Upload-Offset': String(upload.offset),
|
||||||
|
'Upload-Length': String(upload.length), 'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
const complete = (files, name, upload) => {
|
||||||
|
const data = Buffer.concat(upload.chunks, upload.length)
|
||||||
|
files.contents[name] = data
|
||||||
|
files.tree[parent(name)].push({ name: base(name), path: name, size: data.length,
|
||||||
|
modified: new Date().toISOString(), isDir: false, type: type(base(name)) })
|
||||||
|
files.bytes += data.length
|
||||||
|
files.reserved -= upload.length
|
||||||
|
files.uploaded.add(name)
|
||||||
|
files.uploads.delete(name)
|
||||||
|
}
|
||||||
|
app.head(route, (req, res) => {
|
||||||
|
const upload = currentStore().files.uploads.get(locate(req))
|
||||||
|
if (!upload) return res.sendStatus(404)
|
||||||
|
headers(res, upload).status(200).end()
|
||||||
|
})
|
||||||
|
app.post(route, (req, res) => {
|
||||||
|
const name = locate(req)
|
||||||
|
const files = currentStore().files
|
||||||
|
const length = number(req.get('Upload-Length'))
|
||||||
|
if (req.get('Tus-Resumable') !== '1.0.0' || length === null || name === '/' ||
|
||||||
|
name.split('/').some(p => p === '.' || p === '..' || p.includes('\0'))) return res.sendStatus(400)
|
||||||
|
if (!files.tree[parent(name)]) return res.sendStatus(404)
|
||||||
|
if (files.uploads.has(name) || files.tree[parent(name)].some(e => e.path === name)) return res.sendStatus(409)
|
||||||
|
// Reserve the whole declared size, so concurrent uploads cannot evade quota.
|
||||||
|
// Bound empty/abandoned session entries independently of their byte length.
|
||||||
|
if (files.bytes + files.reserved + length > quota || files.uploads.size >= 64) return res.sendStatus(507)
|
||||||
|
const upload = { length, offset: 0, chunks: [], writing: false }
|
||||||
|
files.uploads.set(name, upload)
|
||||||
|
files.reserved += length
|
||||||
|
if (!length) complete(files, name, upload)
|
||||||
|
headers(res, upload).location(req.path).status(201).end()
|
||||||
|
})
|
||||||
|
app.patch(route, async (req, res) => {
|
||||||
|
const name = locate(req)
|
||||||
|
const files = currentStore().files
|
||||||
|
const upload = files.uploads.get(name)
|
||||||
|
if (!upload) return res.sendStatus(404)
|
||||||
|
if (req.get('Tus-Resumable') !== '1.0.0') return res.sendStatus(412)
|
||||||
|
if (req.get('Content-Type') !== 'application/offset+octet-stream') return res.sendStatus(415)
|
||||||
|
if (upload.writing || number(req.get('Upload-Offset')) !== upload.offset) return headers(res, upload).status(409).end()
|
||||||
|
upload.writing = true
|
||||||
|
const chunks = []
|
||||||
|
let size = 0
|
||||||
|
try {
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > 2 * 1024 * 1024 || size > upload.length - upload.offset) {
|
||||||
|
res.status(413).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
// A simultaneous cancellation must never resurrect its staging file.
|
||||||
|
if (files.uploads.get(name) !== upload) return res.sendStatus(404)
|
||||||
|
upload.chunks.push(...chunks)
|
||||||
|
upload.offset += size
|
||||||
|
if (upload.offset === upload.length) complete(files, name, upload)
|
||||||
|
headers(res, upload).status(204).end()
|
||||||
|
} catch {
|
||||||
|
// An interrupted chunk does not advance the committed offset. HEAD tells
|
||||||
|
// the reconnecting client exactly where to resume.
|
||||||
|
if (!res.headersSent && !req.destroyed) res.sendStatus(400)
|
||||||
|
} finally {
|
||||||
|
upload.writing = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.delete(route, (req, res) => {
|
||||||
|
const files = currentStore().files
|
||||||
|
const name = locate(req)
|
||||||
|
const upload = files.uploads.get(name)
|
||||||
|
if (!upload) return res.sendStatus(404)
|
||||||
|
files.reserved -= upload.length
|
||||||
|
files.uploads.delete(name)
|
||||||
|
res.status(204).end()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
# Copy nginx config template
|
# Copy nginx config template
|
||||||
cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
|
cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Use the container's own DNS (Docker and Podman use different addresses).
|
||||||
|
# Resolve the optional external demo only on request, so its DNS outage cannot
|
||||||
|
# prevent the dashboard from starting. Only IP literals enter nginx syntax.
|
||||||
|
resolvers=$(awk '$1 == "nameserver" && $2 ~ /^[0-9a-fA-F:.]+$/ {
|
||||||
|
if (index($2, ":")) printf "[%s] ", $2; else printf "%s ", $2
|
||||||
|
}' /etc/resolv.conf)
|
||||||
|
[ -n "$resolvers" ] || resolvers="1.1.1.1 8.8.8.8"
|
||||||
|
sed -i "s/__ARCHY_DNS_RESOLVERS__/$resolvers/g" /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# Ensure client_max_body_size 0 is present (unlimited uploads)
|
# Ensure client_max_body_size 0 is present (unlimited uploads)
|
||||||
# This is a safety net in case the config template was cached without the directive
|
# This is a safety net in case the config template was cached without the directive
|
||||||
if ! grep -q 'client_max_body_size' /etc/nginx/nginx.conf; then
|
if ! grep -q 'client_max_body_size' /etc/nginx/nginx.conf; then
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ http {
|
|||||||
server {
|
server {
|
||||||
listen 2101;
|
listen 2101;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
resolver __ARCHY_DNS_RESOLVERS__ valid=30s ipv6=off;
|
||||||
|
resolver_timeout 3s;
|
||||||
|
|
||||||
# Demo sign-in seeder, served same-origin to the proxied SPA.
|
# Demo sign-in seeder, served same-origin to the proxied SPA.
|
||||||
location = /__demo/indee-demo-signin.js {
|
location = /__demo/indee-demo-signin.js {
|
||||||
@@ -203,7 +205,8 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass https://indee.tx1138.com;
|
set $indee_host indee.tx1138.com;
|
||||||
|
proxy_pass https://$indee_host$request_uri;
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_ssl_name indee.tx1138.com;
|
proxy_ssl_name indee.tx1138.com;
|
||||||
proxy_set_header Host indee.tx1138.com;
|
proxy_set_header Host indee.tx1138.com;
|
||||||
|
|||||||
+78
-23
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'url'
|
|||||||
import Docker from 'dockerode'
|
import Docker from 'dockerode'
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { installDemoUploads } from './demo-uploads.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -209,7 +210,8 @@ const corsOptions = {
|
|||||||
app.use(cors(corsOptions))
|
app.use(cors(corsOptions))
|
||||||
// Skip JSON body parsing for filebrowser upload routes (binary file bodies)
|
// Skip JSON body parsing for filebrowser upload routes (binary file bodies)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') {
|
if ((req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') ||
|
||||||
|
(req.path.startsWith('/app/filebrowser/api/tus') && req.method === 'PATCH')) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
express.json({ limit: '50mb' })(req, res, next)
|
express.json({ limit: '50mb' })(req, res, next)
|
||||||
@@ -3246,6 +3248,19 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Mesh Networking (LoRa radio via Meshcore)
|
// Mesh Networking (LoRa radio via Meshcore)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
case 'mesh.rnode-config': {
|
||||||
|
return res.json({ result: {
|
||||||
|
settings: { enabled: true, port: null, frequency: 869525000,
|
||||||
|
bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17,
|
||||||
|
airtime_limit_short: null, airtime_limit_long: null },
|
||||||
|
live: null, live_error: 'Radio hardware is unavailable in this demo.',
|
||||||
|
} })
|
||||||
|
}
|
||||||
|
case 'mesh.rnode-config-apply': {
|
||||||
|
// Never make a hardware claim or execute a radio command in the demo.
|
||||||
|
return res.json({ result: { applied: false, confirmed: false, live: null,
|
||||||
|
message: 'Radio settings cannot be applied in this demo. Connect to your own node to configure its radio.' } })
|
||||||
|
}
|
||||||
case 'mesh.status': {
|
case 'mesh.status': {
|
||||||
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
|
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
|
||||||
// Stateful enable/disable so the dev UI can demo the global
|
// Stateful enable/disable so the dev UI can demo the global
|
||||||
@@ -5242,7 +5257,8 @@ const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 *
|
|||||||
|
|
||||||
function fbNormalize(raw) {
|
function fbNormalize(raw) {
|
||||||
// → leading slash, no trailing slash (root stays '/')
|
// → leading slash, no trailing slash (root stays '/')
|
||||||
const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/')
|
// Express already decodes path parameters. Preserve literal %, + and ? names.
|
||||||
|
const p = '/' + (raw || '').split('/').filter(Boolean).join('/')
|
||||||
return p === '/' ? '/' : p.replace(/\/+$/, '')
|
return p === '/' ? '/' : p.replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
function fbParent(p) {
|
function fbParent(p) {
|
||||||
@@ -5275,11 +5291,18 @@ function fbListResponse(res, items) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installDemoUploads(app, { currentStore, quota: FB_QUOTA_BYTES, normalize: fbNormalize, parent: fbParent, base: fbBase, type: fbType })
|
||||||
|
|
||||||
// FileBrowser list resources (root: /api/resources or /api/resources/)
|
// FileBrowser list resources (root: /api/resources or /api/resources/)
|
||||||
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
|
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
|
||||||
const dir = fbNormalize(req.params[0] || '')
|
const dir = fbNormalize(req.params[0] || '')
|
||||||
const items = currentStore().files.tree[dir] || []
|
const { tree, contents } = currentStore().files
|
||||||
fbListResponse(res, items)
|
if (tree[dir]) return fbListResponse(res, tree[dir])
|
||||||
|
const entry = (tree[fbParent(dir)] || []).find(e => e.path === dir)
|
||||||
|
if (!entry) return res.sendStatus(404)
|
||||||
|
const checksums = req.query.checksum === 'sha256' && contents[dir] !== undefined
|
||||||
|
? { sha256: crypto.createHash('sha256').update(contents[dir]).digest('hex') } : undefined
|
||||||
|
res.json({ ...entry, checksums })
|
||||||
})
|
})
|
||||||
|
|
||||||
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
|
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
|
||||||
@@ -5301,33 +5324,38 @@ app.post('/app/filebrowser/api/resources/*', (req, res) => {
|
|||||||
return res.sendStatus(200)
|
return res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File upload — collect body with a quota guard.
|
// Reserve incoming bytes immediately, including concurrent legacy/TUS writes.
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let size = 0
|
let size = 0
|
||||||
let aborted = false
|
let aborted = false
|
||||||
|
const release = () => { store.files.reserved -= size; size = 0 }
|
||||||
req.on('data', (c) => {
|
req.on('data', (c) => {
|
||||||
size += c.length
|
if (aborted) return
|
||||||
if (store.files.bytes + size > FB_QUOTA_BYTES) {
|
if (store.files.bytes + store.files.reserved + c.length > FB_QUOTA_BYTES) {
|
||||||
aborted = true
|
aborted = true
|
||||||
req.destroy()
|
release()
|
||||||
|
res.status(507).send('Demo storage quota exceeded')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
size += c.length
|
||||||
|
store.files.reserved += c.length
|
||||||
chunks.push(c)
|
chunks.push(c)
|
||||||
})
|
})
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)')
|
if (aborted) return
|
||||||
const buf = Buffer.concat(chunks)
|
const buf = Buffer.concat(chunks)
|
||||||
// Replace existing entry of the same name (override=true).
|
release()
|
||||||
const existing = tree[parent].find(e => e.name === name && !e.isDir)
|
if (!tree[parent] || store.files.uploads.has(full) || tree[parent].some(e => e.name === name && e.isDir)) return res.sendStatus(409)
|
||||||
if (existing) store.files.bytes -= existing.size
|
if (store.files.uploaded.has(full)) store.files.bytes -= Buffer.byteLength(contents[full])
|
||||||
tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir))
|
tree[parent] = tree[parent].filter(e => e.name !== name)
|
||||||
const type = fbType(name)
|
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type: fbType(name) })
|
||||||
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type })
|
contents[full] = buf
|
||||||
contents[full] = type === 'text' ? buf.toString('utf-8') : buf
|
|
||||||
store.files.bytes += buf.length
|
store.files.bytes += buf.length
|
||||||
|
store.files.uploaded.add(full)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
})
|
})
|
||||||
req.on('error', () => { if (!res.headersSent) res.sendStatus(400) })
|
req.on('aborted', () => { aborted = true; release() })
|
||||||
|
req.on('error', () => { aborted = true; release(); if (!res.headersSent) res.sendStatus(400) })
|
||||||
})
|
})
|
||||||
|
|
||||||
// FileBrowser delete (file or folder + its subtree)
|
// FileBrowser delete (file or folder + its subtree)
|
||||||
@@ -5337,10 +5365,16 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
|
|||||||
const full = fbNormalize(req.params[0] || '')
|
const full = fbNormalize(req.params[0] || '')
|
||||||
const parent = fbParent(full)
|
const parent = fbParent(full)
|
||||||
if (tree[parent]) {
|
if (tree[parent]) {
|
||||||
const entry = tree[parent].find(e => e.path === full)
|
if (store.files.uploaded.delete(full)) store.files.bytes -= Buffer.byteLength(contents[full])
|
||||||
if (entry && !entry.isDir) store.files.bytes -= entry.size || 0
|
|
||||||
tree[parent] = tree[parent].filter(e => e.path !== full)
|
tree[parent] = tree[parent].filter(e => e.path !== full)
|
||||||
}
|
}
|
||||||
|
// Cancel unfinished sessions in the deleted subtree too.
|
||||||
|
for (const [name, upload] of store.files.uploads) {
|
||||||
|
if (name === full || name.startsWith(full + '/')) {
|
||||||
|
store.files.reserved -= upload.length
|
||||||
|
store.files.uploads.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Recursively drop a directory's children.
|
// Recursively drop a directory's children.
|
||||||
if (tree[full]) {
|
if (tree[full]) {
|
||||||
const stack = [full]
|
const stack = [full]
|
||||||
@@ -5348,7 +5382,10 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
|
|||||||
const d = stack.pop()
|
const d = stack.pop()
|
||||||
for (const e of tree[d] || []) {
|
for (const e of tree[d] || []) {
|
||||||
if (e.isDir) stack.push(e.path)
|
if (e.isDir) stack.push(e.path)
|
||||||
else { store.files.bytes -= e.size || 0; delete contents[e.path] }
|
else {
|
||||||
|
if (store.files.uploaded.delete(e.path)) store.files.bytes -= Buffer.byteLength(contents[e.path])
|
||||||
|
delete contents[e.path]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete tree[d]
|
delete tree[d]
|
||||||
}
|
}
|
||||||
@@ -5362,11 +5399,29 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => {
|
|||||||
const store = currentStore()
|
const store = currentStore()
|
||||||
const { tree, contents } = store.files
|
const { tree, contents } = store.files
|
||||||
const full = fbNormalize(req.params[0] || '')
|
const full = fbNormalize(req.params[0] || '')
|
||||||
const dest = fbNormalize((req.body && req.body.destination) || '')
|
let destination = req.body?.destination || ''
|
||||||
|
if (req.query.action === 'rename') {
|
||||||
|
try { destination = decodeURIComponent(String(req.query.destination || '')) }
|
||||||
|
catch { return res.sendStatus(400) }
|
||||||
|
}
|
||||||
|
const dest = fbNormalize(destination)
|
||||||
if (!dest || dest === '/') return res.sendStatus(400)
|
if (!dest || dest === '/') return res.sendStatus(400)
|
||||||
const parent = fbParent(full)
|
const parent = fbParent(full)
|
||||||
const entry = (tree[parent] || []).find(e => e.path === full)
|
const entry = (tree[parent] || []).find(e => e.path === full)
|
||||||
if (!entry) return res.sendStatus(404)
|
if (!entry) return res.sendStatus(404)
|
||||||
|
const newParent = fbParent(dest)
|
||||||
|
if (!tree[newParent]) return res.sendStatus(404)
|
||||||
|
if (dest === full) return res.sendStatus(200)
|
||||||
|
const replaced = tree[newParent].find(e => e.path === dest)
|
||||||
|
if (replaced && (replaced.isDir || req.query.override !== 'true')) return res.sendStatus(409)
|
||||||
|
if (store.files.uploaded.has(dest)) {
|
||||||
|
store.files.bytes -= Buffer.byteLength(contents[dest])
|
||||||
|
store.files.uploaded.delete(dest)
|
||||||
|
}
|
||||||
|
tree[parent] = tree[parent].filter(e => e !== entry)
|
||||||
|
tree[newParent] = tree[newParent].filter(e => e.path !== dest)
|
||||||
|
tree[newParent].push(entry)
|
||||||
|
if (store.files.uploaded.delete(full)) store.files.uploaded.add(dest)
|
||||||
const newName = fbBase(dest)
|
const newName = fbBase(dest)
|
||||||
entry.name = newName
|
entry.name = newName
|
||||||
entry.path = dest
|
entry.path = dest
|
||||||
@@ -6071,7 +6126,7 @@ function makeSessionStore() {
|
|||||||
userState: seedUserState(),
|
userState: seedUserState(),
|
||||||
mockState: seedMockState(),
|
mockState: seedMockState(),
|
||||||
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
|
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
|
||||||
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 },
|
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0, reserved: 0, uploads: new Map(), uploaded: new Set() },
|
||||||
// Mesh chat mutable state: messages sent this session + attachment bytes
|
// Mesh chat mutable state: messages sent this session + attachment bytes
|
||||||
// (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo
|
// (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo
|
||||||
// visitor's uploads are never visible to another.
|
// visitor's uploads are never visible to another.
|
||||||
@@ -6348,7 +6403,7 @@ async function hydrateRealTestnetTxids() {
|
|||||||
} catch { /* offline — keep mock hashes */ }
|
} catch { /* offline — keep mock hashes */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', async () => {
|
server.listen(PORT, process.env.MOCK_BACKEND_HOST || '0.0.0.0', async () => {
|
||||||
const runtime = await isContainerRuntimeAvailable()
|
const runtime = await isContainerRuntimeAvailable()
|
||||||
|
|
||||||
// Initialize package data from Docker
|
// Initialize package data from Docker
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.22-alpha",
|
"version": "1.9.0-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.22-alpha",
|
"version": "1.9.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.22-alpha",
|
"version": "1.9.0-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
|
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"",
|
||||||
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
||||||
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
|
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
|
||||||
"backend:mock": "node mock-backend.js",
|
"backend:mock": "node mock-backend.js",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<main class="min-h-screen text-white p-6">
|
||||||
|
<div class="max-w-md mx-auto py-8 space-y-4">
|
||||||
|
<p class="text-xs text-orange-200">UI PREVIEW · SAMPLE DATA · NO WALLET ACCESS</p>
|
||||||
|
<h1 class="text-2xl font-semibold">Bump a transaction</h1>
|
||||||
|
<p class="text-sm text-white/55">Open the transaction list and tap the small Bump button. You can try both supported methods and the custom fee review without spending anything.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('cpfp')">Preview CPFP</button>
|
||||||
|
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('rbf')">Preview RBF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TransactionsModal :key="revision" :show="show" :transactions="transactions" @close="show = false" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||||
|
import { choose } from './rpc'
|
||||||
|
const show = ref(false)
|
||||||
|
const revision = ref(0)
|
||||||
|
const transactions = [{ tx_hash: 'a'.repeat(64), amount_sats: 161794, direction: 'outgoing' as const, num_confirmations: 0, time_stamp: Math.floor(Date.now()/1000)-120, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }]
|
||||||
|
function open(method: string) { choose(method); revision.value++; show.value = true }
|
||||||
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export function useTxExplorer() { return { openTx() {} } }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Archy · Bump preview</title></head><body style="background:#080808"><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import '../../src/style.css'
|
||||||
|
import Preview from './Preview.vue'
|
||||||
|
const router = createRouter({ history: createWebHashHistory(), routes: [{path: '/:pathMatch(.*)*', component: {template: '<div />'}}] })
|
||||||
|
createApp(Preview).use(router).use(createI18n({ legacy: false, locale: 'en', messages: {en: {common: {done: 'Done', copy: 'Copy'}, transactions: {title: 'Transactions', unconfirmed: 'Pending', minutesAgo: '{count}m ago', confirmations: '{count} confirmations'}}} })).mount('#app')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Standalone preview only. No network calls and no wallet access.
|
||||||
|
export let method = 'cpfp'
|
||||||
|
let submitted = false
|
||||||
|
let sweepFee = 618
|
||||||
|
export function choose(value: string) { method = value; submitted = false }
|
||||||
|
export const rpcClient = { async call(request: { method: string; params?: Record<string, unknown> }) {
|
||||||
|
if (request.method === 'lnd.bump-status') return submitted
|
||||||
|
? { status: 'mempool', message: 'Preview: fee bump accepted. No real transaction was sent.', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: sweepFee, quote: { method } }
|
||||||
|
: { status: 'none' }
|
||||||
|
if (request.method === 'lnd.bump-submit') { submitted = true; return { status: 'registered', message: 'Preview: bump registered. No real transaction was sent.' } }
|
||||||
|
if (request.method !== 'lnd.bump-quote') throw new Error('Wallet access is disabled in this preview')
|
||||||
|
const rate = Number(request.params?.sat_per_vbyte || 3)
|
||||||
|
const budget = Math.max(rate * 254 - 144, method === 'rbf' ? 763 : 112)
|
||||||
|
sweepFee = budget
|
||||||
|
return { quote_id: 'preview', txid: request.params?.txid, expires_at: Math.floor(Date.now()/1000)+60,
|
||||||
|
method, recipient_sats: 161650, current_fee_sats: method === 'rbf' ? 794 : 144,
|
||||||
|
additional_fee_sats: budget - (method === 'rbf' ? 650 : 0), total_fee_sats: 144 + budget,
|
||||||
|
budget_sats: budget, rate_sat_vb: rate }
|
||||||
|
} }
|
||||||
@@ -436,13 +436,13 @@
|
|||||||
{
|
{
|
||||||
"id": "nginx-proxy-manager",
|
"id": "nginx-proxy-manager",
|
||||||
"title": "Nginx Proxy Manager",
|
"title": "Nginx Proxy Manager",
|
||||||
"version": "2.12.1",
|
"version": "2.14.0",
|
||||||
"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).",
|
"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",
|
"icon": "/assets/img/app-icons/nginx.svg",
|
||||||
"author": "Nginx Proxy Manager",
|
"author": "Nginx Proxy Manager",
|
||||||
"category": "networking",
|
"category": "networking",
|
||||||
"tier": "optional",
|
"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"
|
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -648,9 +648,9 @@
|
|||||||
{
|
{
|
||||||
"id": "angor-indexer",
|
"id": "angor-indexer",
|
||||||
"title": "Angor Indexer",
|
"title": "Angor Indexer",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
"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.1",
|
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
|
||||||
"author": "Angor / Archipelago",
|
"author": "Angor / Archipelago",
|
||||||
"requires": [
|
"requires": [
|
||||||
"Mempool API",
|
"Mempool API",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.5.32",
|
"versionName": "0.5.34",
|
||||||
"versionCode": 52
|
"versionCode": 54
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user