Compare commits
338
Commits
@@ -7,6 +7,14 @@
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||
!docker/
|
||||
docker/*
|
||||
!docker/bitcoin-ui/
|
||||
!docker/electrs-ui/
|
||||
!docker/lnd-ui/
|
||||
!docker/fedimint-ui/
|
||||
|
||||
# Allow backend source for ISO source builds
|
||||
!core/
|
||||
!scripts/
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Demo images
|
||||
|
||||
# Builds and pushes the public-demo images on every change to the UI / mock
|
||||
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
|
||||
# code (see demo-deploy/ and docs/demo-deployment-design.md).
|
||||
#
|
||||
# Required repo configuration:
|
||||
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
|
||||
# secrets.DEMO_REGISTRY_USER
|
||||
# secrets.DEMO_REGISTRY_TOKEN
|
||||
# Optional:
|
||||
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push demo images
|
||||
runs-on: ubuntu-latest
|
||||
# Skip cleanly on forks / before registry config is set.
|
||||
if: ${{ vars.DEMO_REGISTRY != '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# The demo registry is plain HTTP — teach buildkit to push without TLS
|
||||
# (the host docker daemon needs it in insecure-registries for login too).
|
||||
buildkitd-config-inline: |
|
||||
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
|
||||
http = true
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
|
||||
username: ${{ secrets.DEMO_REGISTRY_USER }}
|
||||
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
|
||||
|
||||
- name: Build & push web
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.web
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Demo images
|
||||
|
||||
# Builds and pushes the public-demo images on every change to the UI / mock
|
||||
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
|
||||
# code (see demo-deploy/ and docs/demo-deployment-design.md).
|
||||
#
|
||||
# Required repo configuration:
|
||||
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
|
||||
# secrets.DEMO_REGISTRY_USER
|
||||
# secrets.DEMO_REGISTRY_TOKEN
|
||||
# Optional:
|
||||
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push demo images
|
||||
runs-on: ubuntu-latest
|
||||
# Skip cleanly on forks / before registry config is set.
|
||||
if: ${{ vars.DEMO_REGISTRY != '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# The demo registry is plain HTTP — teach buildkit to push without TLS
|
||||
# (the host docker daemon needs it in insecure-registries for login too).
|
||||
buildkitd-config-inline: |
|
||||
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
|
||||
http = true
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
|
||||
username: ${{ secrets.DEMO_REGISTRY_USER }}
|
||||
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
|
||||
|
||||
- name: Build & push web
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.web
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "indeedhub"]
|
||||
path = indeedhub
|
||||
url = https://git.tx1138.com/lfg2025/indeehub.git
|
||||
url = http://146.59.87.168:3000/lfg2025/indeehub.git
|
||||
|
||||
@@ -14,3 +14,8 @@ local.properties
|
||||
*.aab
|
||||
*.jks
|
||||
*.keystore
|
||||
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
|
||||
# machine (and the published companion download) signs debug builds identically —
|
||||
# updates then install over the top without an uninstall. Debug keys are not
|
||||
# secret (well-known password "android"); never commit a real release keystore.
|
||||
!/app/debug.keystore
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Companion App — Build, Ship & "App Not Installed" Runbook
|
||||
|
||||
Canonical procedure for releasing the Archipelago Companion Android app and for
|
||||
debugging install failures. Read this before touching the companion release flow.
|
||||
Hard lessons from 2026-06-26 are baked in below — don't relearn them.
|
||||
|
||||
## Ship the companion (the only sanctioned way)
|
||||
|
||||
```bash
|
||||
./Android/ship-companion.sh
|
||||
```
|
||||
|
||||
This calls `scripts/publish-companion-apk.sh` (the single source of truth, also
|
||||
used by the `.githooks/pre-push` hook), which:
|
||||
|
||||
1. **Removes/rejects resource dirs whose names contain spaces.** Empty stray
|
||||
`mipmap-* NNN` dirs (left by icon-export tools) break a *clean* build with
|
||||
`Invalid resource directory name`. Incremental builds hide them — clean builds
|
||||
don't.
|
||||
2. **Always does a CLEAN build** (`:app:clean :app:assembleDebug`).
|
||||
3. **Forces v1 + v2 + v3 signing** via `zipalign` + `apksigner`.
|
||||
4. **Verifies all three schemes** (`apksigner verify --min-sdk-version 21`) and
|
||||
**aborts** if any is missing.
|
||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
||||
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
||||
|
||||
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
||||
skips the clean build and the signature enforcement and is exactly how a broken
|
||||
APK shipped.
|
||||
|
||||
### Bump the version first
|
||||
Edit `Android/app/build.gradle.kts` — `versionCode` (must strictly increase) and
|
||||
`versionName`. The committed value can drift AHEAD of what's actually built into
|
||||
the served APK, so verify the served APK's real version after shipping:
|
||||
`aapt2 dump badging neode-ui/public/packages/archipelago-companion.apk | grep version`.
|
||||
|
||||
## Signing facts (important)
|
||||
|
||||
- Debug builds are signed with the **committed** `Android/app/debug.keystore`
|
||||
(store/key pass `android`, alias `androiddebugkey`) so every machine and the
|
||||
served download share ONE signing key. Cert SHA-256: `D6:22:E0:7E:…:66:4D`.
|
||||
- **AGP silently ignores `enableV1Signing = true` for `minSdk ≥ 24`**, so a plain
|
||||
gradle build produces a **v2-only** APK. The `apksigner` step in the publish
|
||||
script is what actually guarantees v1+v2+v3 — do not remove it.
|
||||
- **Changing the signing key forces every existing install to be uninstalled
|
||||
once.** Android blocks in-place upgrades across different signatures. Treat the
|
||||
keystore as permanent; never regenerate it casually.
|
||||
|
||||
## Debugging "App Not Installed" — DIAGNOSE FIRST
|
||||
|
||||
Do **not** theorize about signing schemes / OEM quirks. Get the real reason:
|
||||
|
||||
```bash
|
||||
adb install ~/Desktop/archipelago-companion-<ver>.apk
|
||||
# -> Failure [INSTALL_FAILED_<REASON>: ...]
|
||||
```
|
||||
|
||||
Map the reason:
|
||||
|
||||
| `INSTALL_FAILED_*` | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `UPDATE_INCOMPATIBLE … signatures do not match` | Old install signed with a **different key** (e.g. pre-shared-keystore per-machine key `58:31:12…`). | Uninstall the old package, then install. **One-time** per device after a key change. |
|
||||
| `INVALID_APK` / parse error | Corrupt/incomplete download or bad signing. | Re-download; re-run the publish script. |
|
||||
| `INSUFFICIENT_STORAGE` | Storage. | Free space. |
|
||||
| `OLDER_SDK` | Device below `minSdk` (26 = Android 8.0). | Unsupported device. |
|
||||
|
||||
> A manual uninstall on the phone may NOT clear `UPDATE_INCOMPATIBLE` if the
|
||||
> package is registered under another user/profile — `pm path <pkg>` under user 0
|
||||
> can show nothing while the conflict persists. `adb uninstall <pkg>` clears it
|
||||
> across all users.
|
||||
|
||||
## Phone / adb safety (non-negotiable)
|
||||
|
||||
When acting on the user's physical phone, be surgical — the user once had all
|
||||
home-screen app layouts wiped by an over-broad action.
|
||||
|
||||
- Default to **read-only** adb (`devices`, `getprop`, `pm path/list`, `dumpsys`).
|
||||
- Mutations (`adb install`, `adb uninstall com.archipelago.app.debug`) only with
|
||||
explicit go-ahead and **scoped to our exact package** — echo it first.
|
||||
- **Never** run launcher/system resets: no `pm clear` on launchers, no
|
||||
`reset-permissions`, no factory wipe, no uninstalling apps you didn't build.
|
||||
|
||||
## Verify the published download after shipping
|
||||
|
||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
||||
what you built and signed:
|
||||
|
||||
```bash
|
||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
curl -sS -o /tmp/live.apk "$URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
```
|
||||
@@ -11,20 +11,40 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 12
|
||||
versionName = "0.4.8"
|
||||
versionCode = 16
|
||||
versionName = "0.4.12"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
|
||||
// machine — and the published companion download — signs debug builds with
|
||||
// the SAME key. Without this, Gradle falls back to each machine's
|
||||
// ~/.android/debug.keystore, so a build from a different machine has a
|
||||
// different signature and the phone rejects the update ("App not installed").
|
||||
getByName("debug") {
|
||||
storeFile = file("debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
|
||||
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
|
||||
// a v2-only sideload with "App not installed" — keep v1 for max compat.
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Separate app ID so a debug/test build installs alongside the
|
||||
// release app instead of colliding on signature.
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
|
||||
Binary file not shown.
@@ -112,6 +112,37 @@ class ServerPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a saved server in place. Matches the existing entry by connection
|
||||
* identity (address/port/scheme) so edits that change the name or password —
|
||||
* or that touch a legacy 4-field entry — still update the right record. If the
|
||||
* edited server is also the active one, the active record is kept in sync.
|
||||
*/
|
||||
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == original.address &&
|
||||
e.port == original.port &&
|
||||
e.useHttps == original.useHttps
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + updated.serialize()
|
||||
|
||||
val isActive = prefs[activeAddressKey] == original.address &&
|
||||
(prefs[activePortKey] ?: "") == original.port &&
|
||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||
if (isActive) {
|
||||
prefs[activeAddressKey] = updated.address
|
||||
prefs[activeHttpsKey] = updated.useHttps
|
||||
prefs[activePortKey] = updated.port
|
||||
prefs[activePasswordKey] = updated.password
|
||||
prefs[activeNameKey] = updated.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
|
||||
@@ -75,6 +75,7 @@ fun NESMenu(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
@@ -87,7 +88,7 @@ fun NESMenu(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,21 +103,39 @@ private fun MenuPanel(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
// The saved server being edited, or null when adding a new one.
|
||||
var editing by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
var nm by remember { mutableStateOf("") }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
|
||||
fun resetForm() {
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
||||
}
|
||||
|
||||
fun startEdit(server: ServerEntry) {
|
||||
editing = server
|
||||
nm = server.name; addr = server.address; pwd = server.password
|
||||
showAdd = false
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
if (addr.isNotBlank()) {
|
||||
if (addr.isBlank()) return
|
||||
val orig = editing
|
||||
if (orig != null) {
|
||||
// Preserve fields the compact form doesn't expose (scheme, port).
|
||||
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
||||
} else {
|
||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false
|
||||
}
|
||||
resetForm()
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -149,6 +168,7 @@ private fun MenuPanel(
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onEdit = { startEdit(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
)
|
||||
}
|
||||
@@ -157,8 +177,8 @@ private fun MenuPanel(
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add server
|
||||
if (showAdd) {
|
||||
// Add / edit server
|
||||
if (showAdd || editing != null) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -168,6 +188,25 @@ private fun MenuPanel(
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
if (editing != null) "Edit Server" else "Add Server",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 1.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Cancel",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
@@ -228,6 +267,7 @@ private fun MenuItem(
|
||||
selected: Boolean = false,
|
||||
labelColor: Color = TextPrimary,
|
||||
onClick: () -> Unit,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
@@ -247,7 +287,16 @@ private fun MenuItem(
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onEdit != null) {
|
||||
Text(
|
||||
"✎",
|
||||
color = TextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
if (onRemove != null) {
|
||||
Text(
|
||||
"✕",
|
||||
|
||||
@@ -216,6 +216,17 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onEditServer = { original, updated ->
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
// If the edited server is the live one, reconnect with the new
|
||||
// address/credentials so the change takes effect immediately.
|
||||
if (original.serialize() == activeServer?.serialize()) {
|
||||
ws.disconnect()
|
||||
prefs.setActiveServer(updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveServer = { server ->
|
||||
scope.launch {
|
||||
prefs.removeSavedServer(server)
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -106,9 +107,50 @@ fun ServerConnectScreen(
|
||||
var useHttps by remember { mutableStateOf(false) }
|
||||
var isConnecting by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
// The saved server currently being edited, or null when adding/connecting.
|
||||
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
fun clearForm() {
|
||||
name = ""
|
||||
address = ""
|
||||
port = ""
|
||||
password = ""
|
||||
useHttps = false
|
||||
passwordVisible = false
|
||||
errorMessage = null
|
||||
}
|
||||
|
||||
fun startEdit(server: ServerEntry) {
|
||||
editingServer = server
|
||||
name = server.name
|
||||
address = server.address
|
||||
port = server.port
|
||||
password = server.password
|
||||
useHttps = server.useHttps
|
||||
passwordVisible = false
|
||||
errorMessage = null
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
editingServer = null
|
||||
clearForm()
|
||||
}
|
||||
|
||||
fun saveEdit() {
|
||||
val original = editingServer ?: return
|
||||
if (address.isBlank()) {
|
||||
errorMessage = "Enter a server address"
|
||||
return
|
||||
}
|
||||
val updated = ServerEntry(address, useHttps, port, password, name)
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(server: ServerEntry) {
|
||||
if (isConnecting) return
|
||||
if (server.address.isBlank()) {
|
||||
@@ -178,7 +220,7 @@ fun ServerConnectScreen(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -324,7 +366,11 @@ fun ServerConnectScreen(
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
if (editingServer != null) {
|
||||
saveEdit()
|
||||
} else {
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
}
|
||||
},
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -389,15 +435,40 @@ fun ServerConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Connect button — glass style
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
if (editingServer != null) {
|
||||
// Save / Cancel while editing an existing saved server
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GlassButton(
|
||||
text = stringResource(R.string.cancel),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
cancelEdit()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.save_changes),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
saveEdit()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Connect button — glass style
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
@@ -407,8 +478,8 @@ fun ServerConnectScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Saved servers
|
||||
if (savedServers.isNotEmpty()) {
|
||||
// Saved servers (hidden while editing one to keep focus on the form)
|
||||
if (editingServer == null && savedServers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.saved_servers),
|
||||
@@ -422,6 +493,7 @@ fun ServerConnectScreen(
|
||||
SavedServerItem(
|
||||
server = server,
|
||||
onConnect = { connect(it) },
|
||||
onEdit = { startEdit(it) },
|
||||
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
|
||||
)
|
||||
}
|
||||
@@ -434,6 +506,7 @@ fun ServerConnectScreen(
|
||||
private fun SavedServerItem(
|
||||
server: ServerEntry,
|
||||
onConnect: (ServerEntry) -> Unit,
|
||||
onEdit: (ServerEntry) -> Unit,
|
||||
onRemove: (ServerEntry) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -476,6 +549,9 @@ private fun SavedServerItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onEdit(server) }) {
|
||||
Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
||||
}
|
||||
IconButton(onClick = { onRemove(server) }) {
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -41,29 +29,24 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -71,6 +54,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -82,6 +67,8 @@ import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
@@ -143,159 +130,6 @@ private fun WebView.applyArchipelagoSettings() {
|
||||
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
|
||||
private fun mainHandler() = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
private fun toast(context: Context, msg: String) {
|
||||
mainHandler().post { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
/** Save raw bytes (decoded from a base64 blob/data download) to the device's
|
||||
* Downloads. Uses MediaStore on API 29+ (no permission needed) and the app's
|
||||
* external files dir on older devices (also permission-free). */
|
||||
private fun saveBase64ToDownloads(
|
||||
context: Context,
|
||||
base64: String,
|
||||
mime: String,
|
||||
filename: String,
|
||||
): Boolean {
|
||||
return try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val name = filename.ifBlank { "download_${System.currentTimeMillis()}" }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
if (mime.isNotBlank()) put(MediaStore.Downloads.MIME_TYPE, mime)
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: return false
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false
|
||||
values.clear()
|
||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
true
|
||||
} else {
|
||||
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (dir != null && !dir.exists()) dir.mkdirs()
|
||||
java.io.File(dir, name).outputStream().use { it.write(bytes) }
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand an http(s) download to the system DownloadManager, forwarding the
|
||||
* WebView's session cookies so authenticated node files (e.g. peer-file
|
||||
* streams) download instead of 401-ing. */
|
||||
private fun enqueueHttpDownload(
|
||||
context: Context,
|
||||
url: String,
|
||||
userAgent: String?,
|
||||
contentDisposition: String?,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
||||
setMimeType(mimeType)
|
||||
CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
|
||||
if (!userAgent.isNullOrEmpty()) addRequestHeader("User-Agent", userAgent)
|
||||
setTitle(name)
|
||||
setDescription("Downloading…")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
|
||||
} else {
|
||||
setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, name)
|
||||
}
|
||||
}
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
toast(context, "Downloading $name")
|
||||
}
|
||||
|
||||
/** JS that reads a blob: URL the WebView itself created (DownloadManager can't
|
||||
* fetch blob URLs) and hands the bytes back via the ArchipelagoDownload bridge. */
|
||||
private fun blobToBase64Js(blobUrl: String, mime: String, filename: String): String = """
|
||||
(function() {
|
||||
try {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '$blobUrl', true);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
var dataUrl = reader.result || '';
|
||||
var base64 = dataUrl.indexOf(',') >= 0 ? dataUrl.split(',')[1] : '';
|
||||
var type = (xhr.response && xhr.response.type) ? xhr.response.type : '$mime';
|
||||
ArchipelagoDownload.saveBase64(base64, type, '$filename');
|
||||
};
|
||||
reader.readAsDataURL(xhr.response);
|
||||
};
|
||||
xhr.send();
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
/** Enable file downloads for this WebView: a JS bridge for blob/data URLs plus a
|
||||
* DownloadListener that routes http(s) through DownloadManager and blob/data
|
||||
* through MediaStore. Safe to call once per WebView. */
|
||||
@SuppressLint("JavascriptInterface")
|
||||
private fun WebView.enableFileDownloads(context: Context) {
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun saveBase64(base64: String, mime: String, filename: String) {
|
||||
val ok = saveBase64ToDownloads(context, base64, mime, filename)
|
||||
toast(context, if (ok) "Saved $filename to Downloads" else "Save failed")
|
||||
}
|
||||
},
|
||||
"ArchipelagoDownload",
|
||||
)
|
||||
setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
||||
try {
|
||||
when {
|
||||
url.startsWith("blob:") -> {
|
||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||
evaluateJavascript(blobToBase64Js(url, mimeType ?: "", name), null)
|
||||
}
|
||||
url.startsWith("data:") -> {
|
||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||
val comma = url.indexOf(',')
|
||||
val b64 = if (comma >= 0) url.substring(comma + 1) else ""
|
||||
val ok = saveBase64ToDownloads(context, b64, mimeType ?: "", name)
|
||||
toast(context, if (ok) "Saved $name to Downloads" else "Save failed")
|
||||
}
|
||||
else -> enqueueHttpDownload(context, url, userAgent, contentDisposition, mimeType)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
toast(context, "Download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
||||
* can be shown on the loading splash before the WebView reports onReceivedIcon
|
||||
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
||||
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||
return try {
|
||||
val u = Uri.parse(pageUrl)
|
||||
val scheme = u.scheme ?: return null
|
||||
val host = u.host ?: return null
|
||||
val portPart = if (u.port > 0) ":${u.port}" else ""
|
||||
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
||||
as java.net.HttpURLConnection).apply {
|
||||
connectTimeout = 4000
|
||||
readTimeout = 4000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||
@Composable
|
||||
fun WebViewScreen(
|
||||
@@ -308,37 +142,6 @@ fun WebViewScreen(
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// System file-picker plumbing for <input type="file">. onShowFileChooser
|
||||
// stashes the WebView's callback here; the launcher result delivers the
|
||||
// picked URIs back to it (or null on cancel, so the input doesn't hang).
|
||||
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
|
||||
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val cb = pendingFileCallback.value
|
||||
pendingFileCallback.value = null
|
||||
cb?.onReceiveValue(
|
||||
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||
)
|
||||
}
|
||||
val showFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean =
|
||||
{ callback, params ->
|
||||
pendingFileCallback.value?.onReceiveValue(null)
|
||||
pendingFileCallback.value = callback
|
||||
try {
|
||||
val intent = params?.createIntent()
|
||||
?: Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
fileChooserLauncher.launch(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
pendingFileCallback.value = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// A node app that refused iframing, opened in a local WebView overlay.
|
||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
@@ -427,7 +230,6 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
enableFileDownloads(context)
|
||||
settings.apply {
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
@@ -521,6 +323,26 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Node apps (e.g. NetBird) terminate TLS with a
|
||||
// self-signed cert — the dashboard needs a secure
|
||||
// context for OIDC/window.crypto.subtle (#15). The
|
||||
// WebView default is to CANCEL untrusted certs, so
|
||||
// those apps render blank. The user explicitly trusts
|
||||
// their own node, so proceed for same-host certs only;
|
||||
// reject anything else (don't blanket-trust the web).
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: android.webkit.SslErrorHandler?,
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
@@ -539,13 +361,6 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Open the system file browser for <input type="file">.
|
||||
override fun onShowFileChooser(
|
||||
webView: WebView?,
|
||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean = showFileChooser(filePathCallback, fileChooserParams)
|
||||
|
||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||
// for an app that can't be iframed. Capture the target
|
||||
// URL via a throwaway WebView and route it ourselves.
|
||||
@@ -640,33 +455,40 @@ fun WebViewScreen(
|
||||
url = target,
|
||||
serverUrl = serverUrl,
|
||||
onClose = { inAppUrl = null },
|
||||
onShowFileChooser = showFileChooser,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One control in the in-app browser's bottom bar — sized and styled to match
|
||||
* the web app-session mobile bar (52dp tap target, 24dp glyph, muted white). */
|
||||
@Composable
|
||||
private fun FooterButton(iconRes: Int, descRes: Int, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(52.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = stringResource(descRes),
|
||||
tint = Color.White.copy(alpha = 0.72f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
||||
* can be shown on the loading screen before the WebView reports onReceivedIcon
|
||||
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
||||
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||
return try {
|
||||
val u = android.net.Uri.parse(pageUrl)
|
||||
val scheme = u.scheme ?: return null
|
||||
val host = u.host ?: return null
|
||||
val portPart = if (u.port > 0) ":${u.port}" else ""
|
||||
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
||||
as java.net.HttpURLConnection).apply {
|
||||
connectTimeout = 4000
|
||||
readTimeout = 4000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
||||
* shown in an iframe. Loads the app in a local WebView with a loading splash
|
||||
* (app icon + progress) and a bottom control bar matching the web app-session
|
||||
* mobile bar. Same-host navigation stays here; any genuinely external link
|
||||
* escapes to the phone's browser.
|
||||
* shown in an iframe. Loads the app in a local WebView with a centered loading
|
||||
* screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
|
||||
* web mobile-iframe footer (back / forward / reload / open-in-browser / close).
|
||||
* Same-host navigation stays here; any genuinely external link escapes to the
|
||||
* phone's browser.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
@@ -674,21 +496,23 @@ private fun InAppBrowser(
|
||||
url: String,
|
||||
serverUrl: String,
|
||||
onClose: () -> Unit,
|
||||
onShowFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
// The launched app's icon, shown on the loading splash. Seeded by a
|
||||
// best-effort favicon fetch, then upgraded to the real icon once the
|
||||
// WebView reports onReceivedIcon.
|
||||
var appIcon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
|
||||
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||
// loader shows an icon right away instead of staying blank until the page
|
||||
// parses its <head> (which is what made the loader look stuck).
|
||||
LaunchedEffect(url) {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
||||
if (fetched != null && appIcon == null) appIcon = fetched
|
||||
if (fetched != null && favicon == null) favicon = fetched
|
||||
}
|
||||
|
||||
// Back: walk the in-app history first, then close the overlay.
|
||||
@@ -700,16 +524,11 @@ private fun InAppBrowser(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
// Content area: the app WebView, with a thin progress-bar loader pinned
|
||||
// to the top edge while the page loads.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
// WebView + loading overlay fill the area above the bottom control bar.
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
@@ -723,7 +542,6 @@ private fun InAppBrowser(
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
enableFileDownloads(ctx)
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
@@ -735,24 +553,41 @@ private fun InAppBrowser(
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
if (icon != null) appIcon = icon
|
||||
if (icon != null) favicon = icon
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
webView: WebView?,
|
||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean = onShowFileChooser(filePathCallback, fileChooserParams)
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
if (favicon != null) appIcon = favicon
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
loading = false
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
}
|
||||
|
||||
// Self-signed TLS on the node's apps (e.g. NetBird on
|
||||
// :8087) would otherwise be cancelled by the WebView
|
||||
// and render blank. Proceed for the user's own node
|
||||
// (same host); reject any other untrusted cert.
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: android.webkit.SslErrorHandler?,
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
@@ -774,91 +609,93 @@ private fun InAppBrowser(
|
||||
},
|
||||
)
|
||||
|
||||
// Loading splash — app icon + progress bar, covering the white
|
||||
// first-paint flash until the app's own UI is ready.
|
||||
// Centered loading screen — app favicon (or spinner) + title + bar.
|
||||
if (loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.padding(32.dp),
|
||||
.background(SurfaceBlack),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val icon = appIcon
|
||||
if (icon != null) {
|
||||
Image(
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(14.dp)),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val fav = favicon
|
||||
if (fav != null) {
|
||||
Image(
|
||||
bitmap = fav.asImageBitmap(),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(0.55f),
|
||||
modifier = Modifier.width(220.dp),
|
||||
color = BitcoinOrange,
|
||||
trackColor = SurfaceBlack,
|
||||
trackColor = TextMuted.copy(alpha = 0.2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile footer controls — matched to the web app-session bar: five
|
||||
// evenly-spaced buttons over a translucent dark bar with a hairline top
|
||||
// border, sitting above the system navigation bar.
|
||||
Column(
|
||||
// Bottom control bar — mirrors the web mobile-iframe footer.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xF2141414)),
|
||||
.height(56.dp)
|
||||
.background(SurfaceBlack)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color(0x14FFFFFF)),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.heightIn(min = 64.dp)
|
||||
.padding(vertical = 6.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FooterButton(R.drawable.ic_nav_back, R.string.back) {
|
||||
browser?.let { if (it.canGoBack()) it.goBack() }
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_forward, R.string.forward) {
|
||||
browser?.let { if (it.canGoForward()) it.goForward() }
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_refresh, R.string.refresh) {
|
||||
browser?.reload()
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_newtab, R.string.open_in_browser) {
|
||||
openExternalUrl(context, browser?.url ?: url)
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_close, R.string.close) {
|
||||
onClose()
|
||||
}
|
||||
IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Forward",
|
||||
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { browser?.reload() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reload",
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInBrowser,
|
||||
contentDescription = stringResource(R.string.open_in_browser),
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.close),
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,8 @@
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="server_name_label">Server Name (optional)</string>
|
||||
<string name="server_name_placeholder">My Archipelago</string>
|
||||
<string name="edit_server">Edit</string>
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
</resources>
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#
|
||||
# ./Android/ship-companion.sh
|
||||
#
|
||||
# The actual build/sign/verify/stage is done by scripts/publish-companion-apk.sh
|
||||
# (single source of truth, shared with the pre-push hook). It does a CLEAN build,
|
||||
# forces v1+v2+v3 signing, and ABORTS if any signature scheme is missing — so a
|
||||
# broken or v2-only APK can never be shipped.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
@@ -17,25 +21,15 @@ cd "$ROOT"
|
||||
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
|
||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
||||
OLD_ZIP="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||
|
||||
echo "==> Building debug APK"
|
||||
( cd Android && ./gradlew :app:assembleDebug --console=plain -q )
|
||||
[ -f "$APK" ] || { echo "ERROR: APK not found at $APK" >&2; exit 1; }
|
||||
echo "==> Building + signing + verifying companion APK"
|
||||
bash scripts/publish-companion-apk.sh
|
||||
|
||||
echo "==> Publishing -> $DEST"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp "$APK" "$DEST"
|
||||
# Drop the legacy zipped artifact so the served download is the raw APK only.
|
||||
if [ -f "$OLD_ZIP" ]; then
|
||||
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
|
||||
fi
|
||||
[ -f "$DEST" ] || { echo "ERROR: served APK not found at $DEST" >&2; exit 1; }
|
||||
|
||||
git add "$DEST"
|
||||
if git diff --cached --quiet; then
|
||||
echo "==> Nothing to commit (working tree + APK unchanged)"
|
||||
if git diff --cached --quiet -- "$DEST"; then
|
||||
echo "==> Nothing to commit (APK unchanged)"
|
||||
else
|
||||
git commit -q -m "chore(android): update companion apk download"
|
||||
echo "==> Committed"
|
||||
|
||||
+26
-25
@@ -1,33 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.00-alpha (2026-06-18)
|
||||
## v1.7.100-alpha (2026-07-14)
|
||||
|
||||
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
|
||||
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
|
||||
- Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.
|
||||
- Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.
|
||||
- The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.
|
||||
- Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.
|
||||
- Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).
|
||||
- Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.
|
||||
- Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.
|
||||
- Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.
|
||||
- Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar.
|
||||
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
|
||||
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
|
||||
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
|
||||
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
|
||||
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
|
||||
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
|
||||
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
|
||||
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
|
||||
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
|
||||
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
|
||||
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
|
||||
### Also in this release
|
||||
|
||||
All the v1.7.99-alpha features are included as well:
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
|
||||
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
|
||||
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
|
||||
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
|
||||
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
|
||||
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
|
||||
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
|
||||
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
|
||||
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
|
||||
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
|
||||
- Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai <your question>" in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel.
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute.
|
||||
- Mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box.
|
||||
- You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts.
|
||||
- Federated Archipelago nodes now appear on the Mesh Map.
|
||||
- Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons.
|
||||
- BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels.
|
||||
- Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint.
|
||||
- The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon.
|
||||
- Six placeholder "apps" that were just web bookmarks (484.kitchen, arch-presentation, call-the-operator, nwnn, syntropy-institute, t-zero) are gone from the store.
|
||||
- The Bitcoin dashboard works fully offline (no more loading its styling from the internet), Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart/health-check loops.
|
||||
- Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot.
|
||||
- Consistent dropdowns, toggles, tabs, and modal styling across Settings, Federation, and the rest of the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings.
|
||||
- A fresh node no longer reinstalls apps just because their definition file exists on disk — only apps you actually installed come back.
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
criterion is met and the priority banner is demoted. Next exit-criteria: the
|
||||
**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D.
|
||||
|
||||
**For day-to-day work, use `docs/UNIFIED-TASK-TRACKER.md`** — the consolidated,
|
||||
priority-ordered "what's left" list across the 1.8.0 OTA and master-plan docs
|
||||
(fastest/simplest tasks first). It supersedes hunting through the two source docs
|
||||
below for open items; those remain the narrative/history.
|
||||
|
||||
**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan
|
||||
for the north star: a world-class, **developer-ready app platform** where every app
|
||||
is manifest-driven, manifests ship via the **signed registry** (not OTA disk files),
|
||||
@@ -18,9 +23,31 @@ Detailed sub-plans (all linked from the master):
|
||||
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
|
||||
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md`
|
||||
- External/decentralized marketplace for devs → `docs/marketplace-protocol.md`
|
||||
- Current per-app state → `docs/app-registry-status-2026-06-21.md`
|
||||
- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md`
|
||||
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
|
||||
|
||||
## Commit & push every unit of work (never violate)
|
||||
|
||||
**The #1 process rule: work is not "done" until it is committed AND pushed.** This
|
||||
exists because finished work has been lost/clobbered by sitting uncommitted in the
|
||||
shared tree across agents and sessions. To prevent that:
|
||||
|
||||
- **Commit each feature/fix the moment it works** — one focused, self-contained
|
||||
commit per logical change (it compiles and its targeted tests pass). Do not let
|
||||
unrelated changes accumulate uncommitted.
|
||||
- **Push immediately after committing** so nothing lives only on one machine. `main`
|
||||
is protected → push via `git push gitea-ai main` (account `ai`, see the memory
|
||||
note); feature branches push to their own remote.
|
||||
- **Never leave a stack of finished work uncommitted** overnight or when handing off
|
||||
between agents — if you must pause mid-change, commit a clearly-labelled WIP
|
||||
checkpoint rather than leaving it dirty.
|
||||
- **Stage explicitly by path** (`git add <paths>`) when another agent's uncommitted
|
||||
work shares the tree — never `git add -A` / `git commit -a`, which clobbers or
|
||||
entangles their changes.
|
||||
- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is
|
||||
done offline; artifacts (catalog/manifest) are signed, not the keys.
|
||||
- Commit messages end with the `Co-Authored-By: Claude …` trailer.
|
||||
|
||||
## Invariants (never violate)
|
||||
|
||||
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
|
||||
|
||||
+3
-3
@@ -8,9 +8,9 @@ Be respectful. We follow the [Contributor Covenant](https://www.contributor-cove
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/archy.git`
|
||||
3. Set up the dev environment (see `docs/development-setup.md`)
|
||||
1. Fork the repository on the project's Gitea instance
|
||||
2. Clone your fork: `git clone <your-fork-url>/archy.git`
|
||||
3. Set up the dev environment (see `docs/developer-guide.md`)
|
||||
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
|
||||
## Development Setup
|
||||
|
||||
+2
-2
@@ -122,7 +122,7 @@ echo ""
|
||||
# Install custom app dependencies
|
||||
echo "Installing custom app dependencies..."
|
||||
|
||||
for app in did-wallet endurain morphos-server router; do
|
||||
for app in did-wallet morphos-server router; do
|
||||
if [ -d "apps/$app" ]; then
|
||||
echo " - Installing $app dependencies..."
|
||||
cd "apps/$app"
|
||||
@@ -161,6 +161,6 @@ echo " http://localhost:8100"
|
||||
echo ""
|
||||
echo "For more information, see:"
|
||||
echo " - README.md"
|
||||
echo " - docs/development-setup.md"
|
||||
echo " - docs/developer-guide.md"
|
||||
echo " - apps/QUICKSTART.md"
|
||||
echo ""
|
||||
|
||||
@@ -2,71 +2,99 @@
|
||||
|
||||
> Self-Sovereign Bitcoin Node OS
|
||||
|
||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
|
||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
|
||||
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## Philosophy
|
||||
|
||||
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
|
||||
|
||||
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
|
||||
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
|
||||
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
|
||||
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
|
||||
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
|
||||
|
||||
## Features
|
||||
|
||||
### Bitcoin Infrastructure
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management
|
||||
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
|
||||
- **LND** and **Core Lightning** with channel management
|
||||
- **ElectrumX** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
|
||||
|
||||
### Self-Hosted Apps (29)
|
||||
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||
### Self-Hosted Apps (50+)
|
||||
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
|
||||
|
||||
### Mesh Networking (tri-protocol)
|
||||
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
|
||||
- End-to-end encryption with X3DH key agreement + double-ratchet
|
||||
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
|
||||
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
|
||||
|
||||
### Decentralized Identity
|
||||
- Ed25519 node identity with DID Documents (did:key)
|
||||
- Multi-identity management (Personal/Business/Anonymous)
|
||||
- W3C Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) with bidirectional sync over Tor
|
||||
- Nostr relay integration and NIP-07 signing for iframe apps
|
||||
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
|
||||
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
|
||||
|
||||
### Multi-Node Federation
|
||||
- Invite-based node joining over Tor hidden services
|
||||
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||
- Bidirectional DWN state sync between federated nodes
|
||||
- File sharing with access controls (free/peers-only/paid)
|
||||
|
||||
### Mesh Networking
|
||||
- LoRa radio communication via Meshcore protocol
|
||||
- Device discovery and mesh routing
|
||||
- Off-grid Bitcoin balance checks (planned)
|
||||
- State sync and app deployment across federated nodes
|
||||
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
|
||||
|
||||
### System Updates
|
||||
- OTA updates from self-hosted Gitea (git.tx1138.com) with SHA256 verification
|
||||
- Three update modes: Manual, Daily Check, Auto Apply (3 AM window)
|
||||
- Rollback support with automatic backup before applying
|
||||
- Full UI for update management in Settings
|
||||
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
|
||||
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
|
||||
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
|
||||
|
||||
### Security
|
||||
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2id password hashing
|
||||
- Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
|
||||
- TOTP two-factor authentication
|
||||
- Per-endpoint rate limiting, CSRF protection, input validation
|
||||
- AppArmor profiles for container confinement
|
||||
- Tor hidden services for all inter-node communication
|
||||
- All crypto and container dependencies pinned to exact versions
|
||||
- Full penetration test completed (33 findings, all remediated)
|
||||
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
|
||||
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
|
||||
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
|
||||
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
|
||||
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
|
||||
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
|
||||
|
||||
## Roadmap
|
||||
|
||||
**Done**
|
||||
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
|
||||
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
|
||||
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
|
||||
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
|
||||
|
||||
**In progress**
|
||||
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
|
||||
- Quadlet default flip fleet-wide + container-flapping elimination
|
||||
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
|
||||
|
||||
**Planned**
|
||||
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
|
||||
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
|
||||
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
|
||||
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
|
||||
|
||||
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install from ISO
|
||||
|
||||
1. Download the ISO for your architecture (x86_64 or ARM64)
|
||||
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
|
||||
2. Flash to USB drive with Balena Etcher or `dd`
|
||||
3. Boot from USB on target hardware
|
||||
4. Follow the automated installer
|
||||
5. Access the web UI at `http://<device-ip>`
|
||||
6. Set your password and start the onboarding wizard
|
||||
3. Boot from USB on target hardware and follow the automated installer
|
||||
4. Access the web UI at `http://<device-ip>`
|
||||
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
@@ -75,7 +103,7 @@ Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwa
|
||||
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
|
||||
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
|
||||
|
||||
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for full Bitcoin node)
|
||||
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -94,7 +122,15 @@ npm run type-check # TypeScript validation
|
||||
npm run build # Production build → web/dist/neode-ui/
|
||||
```
|
||||
|
||||
### Deploy to Server
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd core # Rust workspace root (no Cargo.toml at repo root)
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Deploy to a Test Node
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||
@@ -104,54 +140,57 @@ npm run build # Production build → web/dist/neode-ui/
|
||||
### Release (tarball-only)
|
||||
|
||||
Releases ship as a backend binary and a frontend tarball referenced by
|
||||
`releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
|
||||
`releases/manifest.json`, published to the self-hosted Gitea release server.
|
||||
|
||||
```bash
|
||||
./scripts/create-release.sh 1.2.3
|
||||
git push gitea-local main --tags
|
||||
git push gitea-vps2 main --tags
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
ISO builds are archived under `image-recipe/_archived/` and not part of the
|
||||
release deliverable.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Debian 13 (Trixie)
|
||||
├── Rootless Podman (30 containers, archy-net DNS)
|
||||
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
|
||||
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||
│ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
|
||||
│ ├── core/container/ — PodmanClient (REST API socket), manifests, health
|
||||
│ ├── core/security/ — AppArmor, secrets, Cosign image verification
|
||||
│ └── 6 more crates — models, helpers, js-engine, performance, etc.
|
||||
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind)
|
||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
|
||||
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
|
||||
│ │ federation, wallet, updates, marketplace
|
||||
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
|
||||
│ │ health monitor, signed app catalog
|
||||
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
|
||||
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
|
||||
│ └── core/performance/ — resource limits
|
||||
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
|
||||
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
|
||||
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
|
||||
└── System Tor (hidden services, SOCKS5 proxy)
|
||||
```
|
||||
|
||||
~49,000 lines of Rust | ~47,000 lines of TypeScript/Vue | 78 shell scripts | 30 container apps
|
||||
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [Architecture](docs/architecture.md) | System design, codebase stats, data paths |
|
||||
| [Architecture Review (HTML)](docs/architecture-review.html) | Interactive guide with diagrams and learning path |
|
||||
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
|
||||
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||
| [API Reference](docs/api-reference.md) | Complete RPC endpoint reference |
|
||||
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
|
||||
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
||||
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||
| [Security Audit](docs/security-code-audit-2026-03.md) | Penetration test findings |
|
||||
| [Master Plan](docs/MASTER_PLAN.md) | Phased roadmap and task tracking |
|
||||
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
||||
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
||||
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`feature/description`)
|
||||
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
||||
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
@@ -160,4 +199,4 @@ Debian 13 (Trixie)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Debian](https://www.debian.org/)
|
||||
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
|
||||
|
||||
@@ -21,7 +21,7 @@ Add an entry to `catalog.json`:
|
||||
"icon": "/assets/img/app-icons/my-app.svg",
|
||||
"author": "Author",
|
||||
"category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0",
|
||||
"repoUrl": "https://github.com/...",
|
||||
"containerConfig": {
|
||||
"ports": ["8080:8080"],
|
||||
|
||||
+30
-36
@@ -39,7 +39,7 @@
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.svg",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
@@ -172,7 +172,7 @@
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -195,7 +195,7 @@
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostr.svg",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
@@ -214,31 +214,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "meshtastic",
|
||||
"title": "Meshtastic",
|
||||
"version": "2-daily-alpine",
|
||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||
"author": "Meshtastic",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||
"repoUrl": "https://github.com/meshtastic/firmware",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"4403:4403"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
||||
],
|
||||
"env": [
|
||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||
"MESHTASTIC_SERIAL=true"
|
||||
],
|
||||
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
@@ -294,12 +269,12 @@
|
||||
"id": "fedimint-clientd",
|
||||
"title": "Fedimint Client",
|
||||
"version": "0.8.0",
|
||||
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1",
|
||||
"repoUrl": "https://github.com/minmoto/fmcd"
|
||||
},
|
||||
{
|
||||
@@ -310,7 +285,7 @@
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -323,6 +298,25 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
@@ -346,8 +340,8 @@
|
||||
{
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "1.90.0",
|
||||
"description": "High-performance photo and video backup with ML.",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
@@ -453,13 +447,13 @@
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "0.71.2",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
||||
### Custom & External
|
||||
- **indeedhub** — Bitcoin documentary streaming (custom build)
|
||||
- **router** — Mesh routing and network management
|
||||
- **botfights**, **nwnn**, **484-kitchen**, **call-the-operator**, **arch-presentation**, **syntropy-institute**, **t-zero** — External web apps
|
||||
- **botfights** — External web app
|
||||
|
||||
## Manifest Format
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: archy-btcpay-db
|
||||
name: BTCPay Postgres
|
||||
version: 15.17
|
||||
version: "15.17"
|
||||
description: Postgres backend for BTCPay and NBXplorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/postgres:15.17
|
||||
image: 146.59.87.168:3000/lfg2025/postgres:15.17
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: MariaDB backend for the mempool explorer stack.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mariadb:11.4.10
|
||||
image: 146.59.87.168:3000/lfg2025/mariadb:11.4.10
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
|
||||
@@ -33,7 +33,10 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
# 127.0.0.1 not localhost: the image's wget resolves localhost to ::1 (IPv6)
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: BTCPay blockchain indexer service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
|
||||
image: 146.59.87.168:3000/lfg2025/nbxplorer:2.6.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
|
||||
# No official upstream image exists (their GitLab registry is empty), so we
|
||||
# package the pinned, checksum-verified release binary ourselves and push to
|
||||
# the node registry — same approach as fmcd. Keep the version in lockstep with
|
||||
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BARKD_VERSION=0.3.0
|
||||
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl && \
|
||||
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
|
||||
-o /usr/local/bin/barkd && \
|
||||
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
|
||||
chmod a+x /usr/local/bin/barkd && \
|
||||
apt-get purge -y curl && apt-get autoremove -y && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod a+x /entrypoint.sh
|
||||
|
||||
# The wallet itself is created over REST by the node's Ark bridge
|
||||
# (wallet.ark-* RPCs) — the container just runs the daemon.
|
||||
ENV BARKD_DATADIR=/data \
|
||||
BARKD_BIND_HOST=0.0.0.0 \
|
||||
BARKD_BIND_PORT=3535
|
||||
|
||||
EXPOSE 3535
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Install the node-provided auth secret (64-char hex from the manifest's
|
||||
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
|
||||
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
|
||||
# random token in the datadir and the bridge won't authenticate — so treat a
|
||||
# failed refresh as fatal rather than starting an unreachable daemon.
|
||||
set -eu
|
||||
|
||||
if [ -n "${BARKD_SECRET:-}" ]; then
|
||||
# `secret refresh` prints the Bearer token on stdout — never log it.
|
||||
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
|
||||
unset BARKD_SECRET
|
||||
fi
|
||||
|
||||
exec barkd
|
||||
@@ -0,0 +1,76 @@
|
||||
app:
|
||||
id: barkd
|
||||
name: Ark Wallet
|
||||
version: 0.3.0
|
||||
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
|
||||
|
||||
container:
|
||||
# barkd packaged from the pinned upstream release binary (no usable
|
||||
# upstream image exists — their registry is empty). Built from
|
||||
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
|
||||
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
|
||||
# (validated against barkd 0.3.0 on signet, 2026-07-14).
|
||||
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# The entrypoint installs the shared secret below via `barkd secret
|
||||
# refresh` (so the wallet bridge can derive the matching Bearer token) and
|
||||
# execs the daemon. The Ark wallet itself is created over REST by the
|
||||
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
|
||||
# (default: Second's public signet server) — no host provisioning needed.
|
||||
generated_secrets:
|
||||
- name: barkd-secret
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: BARKD_SECRET
|
||||
secret_file: barkd-secret
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
|
||||
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
|
||||
# starve the node.
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
readonly_root: true
|
||||
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
|
||||
# esplora chain source, plus the published REST port for the wallet
|
||||
# bridge. No inbound requirements beyond that.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
|
||||
# the host (see port_allocator.rs). The Rust bridge targets
|
||||
# http://127.0.0.1:3535.
|
||||
- host: 3535
|
||||
container: 3535
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
|
||||
# on-chain from this datadir (unilateral exit) — include it in backups.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/barkd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BARKD_DATADIR=/data
|
||||
- BARKD_BIND_HOST=0.0.0.0
|
||||
- BARKD_BIND_PORT=3535
|
||||
|
||||
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
|
||||
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
|
||||
# verifies reachability).
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3535
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -1,5 +1,34 @@
|
||||
# Bitcoin Core - uses official image
|
||||
FROM bitcoin/bitcoin:24.0
|
||||
|
||||
# Default user is already 'bitcoin'
|
||||
# No additional setup needed
|
||||
# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release.
|
||||
#
|
||||
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
|
||||
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
|
||||
# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. This Dockerfile
|
||||
# mirrors that image for a manual/local build and replaces the old stale
|
||||
# community base (`FROM bitcoin/bitcoin:24.0`).
|
||||
#
|
||||
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
|
||||
# scripts/build-bitcoin-image.sh core 31.0
|
||||
FROM debian:bookworm-slim
|
||||
ARG BITCOIN_VERSION=31.0
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
useradd -m -u 1000 -s /bin/bash bitcoin; \
|
||||
mkdir -p /home/bitcoin/.bitcoin; \
|
||||
chown -R bitcoin:bitcoin /home/bitcoin
|
||||
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built,
|
||||
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||
# Run as (container) root, like the legacy hand-built :latest image. Rootless
|
||||
# Podman maps container-root to the unprivileged host service user; the manifest
|
||||
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
|
||||
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
|
||||
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
|
||||
# bitcoind crash-loops with "Error initializing block database".
|
||||
WORKDIR /home/bitcoin
|
||||
VOLUME ["/home/bitcoin/.bitcoin"]
|
||||
EXPOSE 8332 8333
|
||||
ENTRYPOINT ["bitcoind"]
|
||||
|
||||
@@ -17,6 +17,13 @@ app:
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
#
|
||||
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
|
||||
# which pushed every IBD "UpdateTip" line through conmon into journald
|
||||
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
|
||||
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
|
||||
# restart) — use that for deep debugging; podman logs only carries
|
||||
# entrypoint/startup errors.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
@@ -36,9 +43,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -64,9 +71,17 @@ app:
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
|
||||
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
|
||||
# dial the container's archy-net alias directly (bitcoin-core:8332),
|
||||
# which needs no publish at all. Do NOT bind the archy-net gateway
|
||||
# (10.89.0.1): rootlessport binds in the HOST netns where that address
|
||||
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
|
||||
# P2P 8333 stays public.
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release.
|
||||
#
|
||||
# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand).
|
||||
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
|
||||
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
|
||||
# (fail-closed, Luke-Jr release key), and tags/pushes
|
||||
# <registry>/bitcoin-knots:<version>. Knots version strings embed a build date,
|
||||
# e.g. 29.3.knots20260508 — the full string is the tag.
|
||||
#
|
||||
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
|
||||
# scripts/build-bitcoin-image.sh knots 29.3.knots20260508
|
||||
FROM debian:bookworm-slim
|
||||
ARG KNOTS_VERSION=29.3.knots20260508
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
useradd -m -u 1000 -s /bin/bash bitcoin; \
|
||||
mkdir -p /home/bitcoin/.bitcoin; \
|
||||
chown -R bitcoin:bitcoin /home/bitcoin
|
||||
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built,
|
||||
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||
# Run as (container) root, like the legacy hand-built :latest image. Rootless
|
||||
# Podman maps container-root to the unprivileged host service user; the manifest
|
||||
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
|
||||
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
|
||||
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
|
||||
# bitcoind crash-loops with "Error initializing block database".
|
||||
WORKDIR /home/bitcoin
|
||||
VOLUME ["/home/bitcoin/.bitcoin"]
|
||||
EXPOSE 8332 8333
|
||||
ENTRYPOINT ["bitcoind"]
|
||||
@@ -17,6 +17,13 @@ app:
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
#
|
||||
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
|
||||
# which pushed every IBD "UpdateTip" line through conmon into journald
|
||||
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
|
||||
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
|
||||
# restart) — use that for deep debugging; podman logs only carries
|
||||
# entrypoint/startup errors.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
@@ -36,9 +43,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -64,9 +71,17 @@ app:
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
|
||||
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
|
||||
# dial the container's archy-net alias directly (bitcoin-knots:8332),
|
||||
# which needs no publish at all. Do NOT bind the archy-net gateway
|
||||
# (10.89.0.1): rootlessport binds in the HOST netns where that address
|
||||
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
|
||||
# P2P 8333 stays public.
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
@@ -13,6 +13,12 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
|
||||
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
|
||||
# Optional: nodes without LND run btcpay without an internal node.
|
||||
- key: BTCPAY_BTCLIGHTNING
|
||||
secret_file: btcpay-lnd-connection
|
||||
optional: true
|
||||
derived_env:
|
||||
- key: BTCPAY_HOST
|
||||
template: "{{HOST_IP}}:23000"
|
||||
@@ -50,6 +56,10 @@ app:
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||
- BTCPAY_PROTOCOL=http
|
||||
- BTCPAY_CHAINS=btc
|
||||
# Plugins must live on the persistent volume: the image default
|
||||
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
|
||||
# silently wiped installed plugins.
|
||||
- BTCPAY_PLUGINDIR=/datadir/Plugins
|
||||
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- BTCPAY_BTCRPCUSER=archipelago
|
||||
|
||||
@@ -27,7 +27,7 @@ app:
|
||||
apparmor_profile: did-wallet
|
||||
|
||||
ports:
|
||||
- host: 8083
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@@ -42,7 +42,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8083
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -22,6 +22,7 @@ app:
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- storage: 50Gi
|
||||
- bitcoin:archival
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
|
||||
@@ -9,7 +9,7 @@ app:
|
||||
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
|
||||
# this to the node registry. Pin the tag to match the REST shapes coded in
|
||||
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
|
||||
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
|
||||
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
|
||||
@@ -33,6 +33,11 @@ app:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
# fmcd's embedded iroh networking can hot-loop on relay/hole-punch retries
|
||||
# on NAT'd nodes that reach the federation neither directly nor via iroh's
|
||||
# public relays, pegging its whole allotment. Cap it low so a stuck instance
|
||||
# can't starve the node (steady-state is <3% of a core; joins are brief);
|
||||
# the fmcd-run watchdog additionally restarts a sustained-hot process.
|
||||
cpu_limit: 1
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
|
||||
image: 146.59.87.168:3000/lfg2025/gatewayd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
|
||||
image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
|
||||
@@ -30,7 +30,13 @@ app:
|
||||
disk_limit: 200Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
# Runs as container root over a data tree the legacy installer chowned
|
||||
# to the subuid range (host 100000 = container uid 1). Without
|
||||
# DAC_OVERRIDE the server EACCESes writing upload/encoded-video the
|
||||
# moment the container is recreated against this manifest (latent until
|
||||
# the 2026-07-05 secret-env migration recreated it). Same cap set as
|
||||
# immich-postgres minus the setuid pair it doesn't use.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
|
||||
VERSION="${1:-latest}"
|
||||
REGISTRY="${REGISTRY:-git.tx1138.com}"
|
||||
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
|
||||
NAMESPACE="${NAMESPACE:-lfg2025}"
|
||||
IMAGE_NAME="indeedhub"
|
||||
RUNTIME="${RUNTIME:-podman}"
|
||||
|
||||
@@ -29,13 +29,13 @@ app:
|
||||
apparmor_profile: lightning-stack
|
||||
|
||||
ports:
|
||||
- host: 9737
|
||||
- host: 9738
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
- host: 10010
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
- host: 8087
|
||||
- host: 8091
|
||||
container: 8080
|
||||
protocol: tcp # REST/Web UI
|
||||
|
||||
@@ -53,7 +53,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8087
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /v1/getinfo
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -8,6 +8,13 @@ app:
|
||||
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
# Core — resolved at apply time from host facts. Hardcoding either breaks
|
||||
# LND's chain backend connection on the other (lnd.conf is likewise
|
||||
# resolved in lnd::ensure_config).
|
||||
derived_env:
|
||||
- key: BITCOIND_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
secret_env:
|
||||
- key: BITCOIND_RPCPASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -45,7 +52,6 @@ app:
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-knots
|
||||
- BITCOIND_RPCUSER=archipelago
|
||||
- NETWORK=mainnet
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Backend API for mempool explorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
image: 146.59.87.168:3000/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
@@ -27,6 +27,7 @@ app:
|
||||
version: ">=1.18.0"
|
||||
- app_id: archy-mempool-db
|
||||
version: ">=11.4.10"
|
||||
- bitcoin:archival
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
|
||||
@@ -13,6 +13,7 @@ app:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=24.0"
|
||||
- storage: 20Gi
|
||||
- bitcoin:archival
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
@@ -30,7 +31,7 @@ app:
|
||||
|
||||
ports:
|
||||
- host: 4080
|
||||
container: 4080
|
||||
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Meshtastic - uses official image
|
||||
FROM meshtastic/meshtastic:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,69 +0,0 @@
|
||||
app:
|
||||
id: meshtastic
|
||||
name: Meshtastic
|
||||
version: 2-daily-alpine
|
||||
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
||||
|
||||
container:
|
||||
image: docker.io/meshtastic/meshtasticd:daily-alpine
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_ADMIN, SYS_ADMIN] # Required for LoRa radio access
|
||||
readonly_root: false # Needs write access for device management
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: host # Requires host network for radio access
|
||||
apparmor_profile: meshtastic
|
||||
|
||||
ports:
|
||||
- host: 4403
|
||||
container: 4403
|
||||
protocol: tcp # Meshtastic TCP API
|
||||
|
||||
devices:
|
||||
- /dev/ttyUSB0 # LoRa radio device (if connected)
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/meshtastic
|
||||
target: /var/lib/meshtasticd
|
||||
options: [rw]
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/meshtastic/config.yaml
|
||||
content: |
|
||||
General:
|
||||
MACAddress: AA:BB:CC:DD:EE:01
|
||||
Webserver:
|
||||
Port: 4403
|
||||
|
||||
environment:
|
||||
- MESHTASTIC_PORT=/dev/ttyUSB0
|
||||
- MESHTASTIC_SERIAL=true
|
||||
|
||||
health_check:
|
||||
type: cmd
|
||||
endpoint: test -f /var/lib/meshtasticd/config.yaml
|
||||
interval: 30s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
networking:
|
||||
mesh_enabled: true
|
||||
local_network_access: true
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/meshcore.svg
|
||||
category: networking
|
||||
tier: recommended
|
||||
repo: https://github.com/meshtastic/firmware
|
||||
@@ -27,7 +27,7 @@ app:
|
||||
apparmor_profile: morphos-server
|
||||
|
||||
ports:
|
||||
- host: 8086
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@@ -43,7 +43,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8086
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
app:
|
||||
id: netbird-dashboard
|
||||
name: NetBird Dashboard
|
||||
version: "2.38.0"
|
||||
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
|
||||
category: networking
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption).
|
||||
# Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to.
|
||||
container_name: netbird-dashboard
|
||||
|
||||
container:
|
||||
image: docker.io/netbirdio/dashboard:v2.38.0
|
||||
pull_policy: if-not-present
|
||||
network: netbird-net
|
||||
network_aliases: [netbird-dashboard]
|
||||
# The dashboard SPA bakes its API/OIDC base URL from these at container
|
||||
# start. They must point at the proxy's public HTTPS origin (8087) so the
|
||||
# browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15).
|
||||
# {{HOST_IP}} is the node's primary host IP, resolved at apply time.
|
||||
derived_env:
|
||||
- key: NETBIRD_MGMT_API_ENDPOINT
|
||||
template: "https://{{HOST_IP}}:8087"
|
||||
- key: NETBIRD_MGMT_GRPC_API_ENDPOINT
|
||||
template: "https://{{HOST_IP}}:8087"
|
||||
- key: AUTH_AUTHORITY
|
||||
template: "https://{{HOST_IP}}:8087/oauth2"
|
||||
|
||||
dependencies:
|
||||
- app_id: netbird-server
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. The dashboard image runs
|
||||
# nginx (master as root, drops workers) binding :80 — needs the worker-drop
|
||||
# caps + NET_BIND_SERVICE for the privileged port.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
# Internal only — reached container-to-container by the proxy via netbird-net.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment:
|
||||
- AUTH_AUDIENCE=netbird-dashboard
|
||||
- AUTH_CLIENT_ID=netbird-dashboard
|
||||
- AUTH_CLIENT_SECRET=
|
||||
- USE_AUTH0=false
|
||||
- AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
- AUTH_REDIRECT_URI=/nb-auth
|
||||
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
- NETBIRD_TOKEN_SOURCE=idToken
|
||||
- NGINX_SSL_PORT=443
|
||||
- LETSENCRYPT_DOMAIN=none
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:80
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
metadata:
|
||||
author: NetBird
|
||||
icon: /assets/img/app-icons/netbird.svg
|
||||
website: https://netbird.io
|
||||
repo: https://github.com/netbirdio/dashboard
|
||||
license: BSD-3-Clause
|
||||
tags:
|
||||
- networking
|
||||
- vpn
|
||||
- dashboard
|
||||
@@ -0,0 +1,122 @@
|
||||
app:
|
||||
id: netbird-server
|
||||
name: NetBird Server
|
||||
version: "0.71.2"
|
||||
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
|
||||
category: networking
|
||||
|
||||
# Hyphen name matches the runtime references (crash_recovery / dependencies /
|
||||
# config startup order) + the live container, so on an existing node the
|
||||
# orchestrator ADOPTS the running server rather than recreating it (data +
|
||||
# the sqlite store under /var/lib/netbird preserved). Alias `netbird-server`
|
||||
# is the short hostname the proxy's nginx proxies/grpc-passes to.
|
||||
container_name: netbird-server
|
||||
|
||||
container:
|
||||
image: docker.io/netbirdio/netbird-server:0.71.2
|
||||
pull_policy: if-not-present
|
||||
network: netbird-net
|
||||
network_aliases: [netbird-server]
|
||||
# The relay authSecret and the sqlite store encryptionKey are base64 keys
|
||||
# (the server base64-decodes them to recover raw bytes — hex would decode to
|
||||
# the wrong value). Generated once and reused: ensure_generated_secrets
|
||||
# no-ops when the file already exists, so a re-render of config.yaml on an
|
||||
# adopted node keeps the same keys (regenerating would orphan the store).
|
||||
generated_secrets:
|
||||
- name: netbird-relay-auth-secret
|
||||
kind: base64
|
||||
- name: netbird-store-encryption-key
|
||||
kind: base64
|
||||
# Pass the rendered config explicitly, mirroring the legacy `--config` arg.
|
||||
custom_args: ["--config", "/etc/netbird/config.yaml"]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. The server binds :80
|
||||
# (management/signal/relay HTTP + gRPC) inside the container — a privileged
|
||||
# port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged).
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8086
|
||||
container: 80
|
||||
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
|
||||
- host: 3478
|
||||
container: 3478
|
||||
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/netbird/data
|
||||
target: /var/lib/netbird
|
||||
options: [rw]
|
||||
# The rendered config.yaml, read-only. Re-rendered on every reconcile from
|
||||
# host facts + the base64 secrets; idempotent (stable bytes → no restart).
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/netbird/config.yaml
|
||||
target: /etc/netbird/config.yaml
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
# The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's
|
||||
# public origin is https on 8087 — the dashboard needs a secure context for
|
||||
# OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir.
|
||||
files:
|
||||
- path: /var/lib/archipelago/netbird/config.yaml
|
||||
overwrite: true
|
||||
content: |
|
||||
server:
|
||||
listenAddress: ":80"
|
||||
exposedAddress: "https://{{HOST_IP}}:8087"
|
||||
stunPorts:
|
||||
- 3478
|
||||
metricsPort: 9090
|
||||
healthcheckAddress: ":9000"
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
authSecret: "{{secret:netbird-relay-auth-secret}}"
|
||||
dataDir: "/var/lib/netbird"
|
||||
auth:
|
||||
issuer: "https://{{HOST_IP}}:8087/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
- "https://{{HOST_IP}}:8087/nb-auth"
|
||||
- "https://{{HOST_IP}}:8087/nb-silent-auth"
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "https://{{HOST_IP}}:8087/"
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
encryptionKey: "{{secret:netbird-store-encryption-key}}"
|
||||
|
||||
# TCP liveness on the management port. Binds at startup, stays green; an http
|
||||
# check of /oauth2 would false-fail while the issuer warms up.
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:80
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
metadata:
|
||||
author: NetBird
|
||||
icon: /assets/img/app-icons/netbird.svg
|
||||
website: https://netbird.io
|
||||
repo: https://github.com/netbirdio/netbird
|
||||
license: BSD-3-Clause
|
||||
tags:
|
||||
- networking
|
||||
- vpn
|
||||
- wireguard
|
||||
- mesh
|
||||
@@ -0,0 +1,182 @@
|
||||
app:
|
||||
id: netbird
|
||||
name: NetBird
|
||||
version: "2.38.0"
|
||||
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
|
||||
category: networking
|
||||
|
||||
# The user-facing launcher (app_id + container both "netbird", matching the
|
||||
# runtime references + the live container so the orchestrator adopts it). This
|
||||
# is the nginx that terminates TLS on 8087 and fans out to the dashboard +
|
||||
# server by their short aliases on netbird-net.
|
||||
container_name: netbird
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
pull_policy: if-not-present
|
||||
network: netbird-net
|
||||
# Self-signed TLS cert materialised before create — the dashboard needs a
|
||||
# secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy
|
||||
# serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user
|
||||
# accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost.
|
||||
generated_certs:
|
||||
- crt: /var/lib/archipelago/netbird/tls.crt
|
||||
key: /var/lib/archipelago/netbird/tls.key
|
||||
|
||||
dependencies:
|
||||
- app_id: netbird-server
|
||||
- app_id: netbird-dashboard
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||
# workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# 8087 publishes the TLS listener (container :443). HTTPS is required for the
|
||||
# dashboard's secure context (issue #15).
|
||||
- host: 8087
|
||||
container: 443
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/netbird/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/netbird/tls.crt
|
||||
target: /etc/nginx/tls.crt
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/netbird/tls.key
|
||||
target: /etc/nginx/tls.key
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
# The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway =
|
||||
# Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE
|
||||
# upstreams so it re-resolves container names per request — without it nginx
|
||||
# pins a container IP at startup and 502s forever once that IP moves on a
|
||||
# restart/reboot (issue #15, observed live on .198). Every #15 fix below
|
||||
# (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to
|
||||
# index.html, /relay websocket) is preserved verbatim from the legacy config.
|
||||
files:
|
||||
- path: /var/lib/archipelago/netbird/nginx.conf
|
||||
overwrite: true
|
||||
content: |
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# netbird's dashboard needs a secure context (window.crypto.subtle for
|
||||
# OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15).
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
|
||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||
# nginx resolves a literal upstream name ONCE at startup and caches it,
|
||||
# so after the IP moves every request 502s with "host unreachable"
|
||||
# (issue #15, observed live on .198: nginx pinned to a dead
|
||||
# netbird-dashboard IP). Fix: point `resolver` at the netbird-net
|
||||
# gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which
|
||||
# forces nginx to re-resolve the container names at request time.
|
||||
resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {
|
||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build
|
||||
# time to one host:port. A single box is reached via several
|
||||
# addresses, so those fetches are cross-origin and the browser
|
||||
# blocks them with no Access-Control-Allow-Origin (#15, live on
|
||||
# .198). Reflect the caller's Origin and answer the CORS preflight.
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {
|
||||
set $nb_server netbird-server;
|
||||
grpc_pass grpc://$nb_server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}
|
||||
|
||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page
|
||||
# in the dashboard bundle, so proxying them straight through 404s —
|
||||
# which crashes the dashboard's auth init and shows "Unauthenticated"
|
||||
# with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth
|
||||
# returned 404). Serve index.html at these paths (URL unchanged) so
|
||||
# react-oidc boots and completes the login / silent-SSO.
|
||||
location ~ ^/(nb-auth|nb-silent-auth) {
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
rewrite ^.*$ /index.html break;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}
|
||||
}
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:443
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Dashboard
|
||||
description: Manage your self-hosted NetBird mesh VPN
|
||||
type: ui
|
||||
port: 8087
|
||||
protocol: https
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: NetBird
|
||||
icon: /assets/img/app-icons/netbird.svg
|
||||
website: https://netbird.io
|
||||
repo: https://github.com/netbirdio/netbird
|
||||
license: BSD-3-Clause
|
||||
tags:
|
||||
- networking
|
||||
- vpn
|
||||
- wireguard
|
||||
- mesh
|
||||
+177
-17
@@ -3,51 +3,211 @@ app:
|
||||
name: Strfry Nostr Relay
|
||||
version: 0.9.0
|
||||
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
|
||||
|
||||
|
||||
container:
|
||||
image: dockurr/strfry:1.0.4
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 5Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
|
||||
ports:
|
||||
- host: 8082
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
|
||||
- host: 8090
|
||||
container: 7777
|
||||
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/strfry
|
||||
target: /strfry
|
||||
target: /app/strfry-db
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- RELAY_NAME=Archipelago Strfry Relay
|
||||
|
||||
# Image default config demands a 1M NOFILES rlimit, above the rootless
|
||||
# user-manager hard cap (524288) — ship the config with nofiles = 0.
|
||||
# Mounting it also skips the entrypoint's copy into /etc, which a
|
||||
# readonly_root container cannot do.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/strfry-config/strfry.conf
|
||||
target: /etc/strfry.conf
|
||||
options: [ro]
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/strfry-config/strfry.conf
|
||||
overwrite: true
|
||||
content: |
|
||||
##
|
||||
## Default strfry config
|
||||
##
|
||||
|
||||
# Directory that contains the strfry LMDB database (restart required)
|
||||
db = "./strfry-db/"
|
||||
|
||||
dbParams {
|
||||
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
|
||||
maxreaders = 256
|
||||
|
||||
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
|
||||
mapsize = 10995116277760
|
||||
|
||||
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
|
||||
noReadAhead = false
|
||||
}
|
||||
|
||||
events {
|
||||
# Maximum size of normalised JSON, in bytes
|
||||
maxEventSize = 65536
|
||||
|
||||
# Events newer than this will be rejected
|
||||
rejectEventsNewerThanSeconds = 900
|
||||
|
||||
# Events older than this will be rejected
|
||||
rejectEventsOlderThanSeconds = 94608000
|
||||
|
||||
# Ephemeral events older than this will be rejected
|
||||
rejectEphemeralEventsOlderThanSeconds = 60
|
||||
|
||||
# Ephemeral events will be deleted from the DB when older than this
|
||||
ephemeralEventsLifetimeSeconds = 300
|
||||
|
||||
# Maximum number of tags allowed
|
||||
maxNumTags = 2000
|
||||
|
||||
# Maximum size for tag values, in bytes
|
||||
maxTagValSize = 1024
|
||||
}
|
||||
|
||||
relay {
|
||||
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
|
||||
bind = "0.0.0.0"
|
||||
|
||||
# Port to open for the nostr websocket protocol (restart required)
|
||||
port = 7777
|
||||
|
||||
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||
nofiles = 0
|
||||
|
||||
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||
realIpHeader = ""
|
||||
|
||||
info {
|
||||
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
|
||||
name = "Archipelago Strfry Relay"
|
||||
|
||||
# NIP-11: Detailed information about relay, free-form
|
||||
description = "Self-hosted strfry Nostr relay on Archipelago."
|
||||
|
||||
# NIP-11: Administrative nostr pubkey, for contact purposes
|
||||
pubkey = ""
|
||||
|
||||
# NIP-11: Alternative administrative contact (email, website, etc)
|
||||
contact = ""
|
||||
|
||||
# NIP-11: URL pointing to an image to be used as an icon for the relay
|
||||
icon = ""
|
||||
|
||||
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
|
||||
nips = ""
|
||||
}
|
||||
|
||||
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
|
||||
maxWebsocketPayloadSize = 131072
|
||||
|
||||
# Maximum number of filters allowed in a REQ
|
||||
maxReqFilterSize = 200
|
||||
|
||||
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
|
||||
autoPingSeconds = 55
|
||||
|
||||
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
|
||||
enableTcpKeepalive = false
|
||||
|
||||
# How much uninterrupted CPU time a REQ query should get during its DB scan
|
||||
queryTimesliceBudgetMicroseconds = 10000
|
||||
|
||||
# Maximum records that can be returned per filter
|
||||
maxFilterLimit = 500
|
||||
|
||||
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
|
||||
maxSubsPerConnection = 20
|
||||
|
||||
writePolicy {
|
||||
# If non-empty, path to an executable script that implements the writePolicy plugin logic
|
||||
plugin = "/app/write-policy.py"
|
||||
}
|
||||
|
||||
compression {
|
||||
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
|
||||
enabled = true
|
||||
|
||||
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
|
||||
slidingWindow = true
|
||||
}
|
||||
|
||||
logging {
|
||||
# Dump all incoming messages
|
||||
dumpInAll = false
|
||||
|
||||
# Dump all incoming EVENT messages
|
||||
dumpInEvents = false
|
||||
|
||||
# Dump all incoming REQ/CLOSE messages
|
||||
dumpInReqs = false
|
||||
|
||||
# Log performance metrics for initial REQ database scans
|
||||
dbScanPerf = false
|
||||
|
||||
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
|
||||
invalidEvents = true
|
||||
}
|
||||
|
||||
numThreads {
|
||||
# Ingester threads: route incoming requests, validate events/sigs (restart required)
|
||||
ingester = 3
|
||||
|
||||
# reqWorker threads: Handle initial DB scan for events (restart required)
|
||||
reqWorker = 3
|
||||
|
||||
# reqMonitor threads: Handle filtering of new events (restart required)
|
||||
reqMonitor = 3
|
||||
|
||||
# negentropy threads: Handle negentropy protocol messages (restart required)
|
||||
negentropy = 2
|
||||
}
|
||||
|
||||
negentropy {
|
||||
# Support negentropy protocol messages
|
||||
enabled = true
|
||||
|
||||
# Maximum records that sync will process before returning an error
|
||||
maxSyncEvents = 1000000
|
||||
}
|
||||
}
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8082
|
||||
# In-container probe: must target the CONTAINER port (7777), not the host
|
||||
# mapping (8090), and 127.0.0.1 explicitly — `localhost` resolves to ::1
|
||||
# inside the image while strfry binds IPv4 0.0.0.0 only (verified on .228:
|
||||
# localhost:7777 refused, 127.0.0.1:7777/health = 200).
|
||||
endpoint: http://127.0.0.1:7777
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
monetization_enabled: true
|
||||
|
||||
Generated
+84
-1
@@ -95,10 +95,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.99-alpha"
|
||||
version = "1.7.100-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
"archipelago-openwrt",
|
||||
"archipelago-performance",
|
||||
"archipelago-security",
|
||||
"argon2",
|
||||
@@ -128,6 +129,7 @@ dependencies = [
|
||||
"hyper-ws-listener",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"libc",
|
||||
"mainline",
|
||||
"mdns-sd",
|
||||
"nostr-sdk",
|
||||
@@ -138,6 +140,7 @@ dependencies = [
|
||||
"reqwest 0.11.27",
|
||||
"sd-notify",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial2-tokio",
|
||||
@@ -167,6 +170,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hex",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"log",
|
||||
@@ -174,12 +178,29 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-openwrt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ssh2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-performance"
|
||||
version = "0.1.0"
|
||||
@@ -2839,6 +2860,32 @@ dependencies = [
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3580,6 +3627,18 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papaya"
|
||||
version = "0.2.4"
|
||||
@@ -3758,6 +3817,12 @@ dependencies = [
|
||||
"spki 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
@@ -4988,6 +5053,18 @@ dependencies = [
|
||||
"der 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"parking_lot 0.12.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -5775,6 +5852,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "9.1.0"
|
||||
|
||||
@@ -4,6 +4,7 @@ resolver = "2"
|
||||
members = [
|
||||
"archipelago",
|
||||
"container",
|
||||
"openwrt",
|
||||
"performance",
|
||||
"security",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.99-alpha"
|
||||
version = "1.7.100-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -22,6 +22,7 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0"
|
||||
@@ -42,6 +43,7 @@ futures-util = "0.3"
|
||||
|
||||
# Our modules
|
||||
archipelago-container = { path = "../container" }
|
||||
archipelago-openwrt = { path = "../openwrt" }
|
||||
archipelago-security = { path = "../security" }
|
||||
archipelago-performance = { path = "../performance" }
|
||||
|
||||
@@ -108,6 +110,7 @@ hkdf = "0.12.4"
|
||||
|
||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||
ciborium = "0.2.2"
|
||||
serde_bytes = "0.11"
|
||||
reed-solomon-erasure = "6.0"
|
||||
mdns-sd = "0.18"
|
||||
|
||||
|
||||
@@ -48,6 +48,17 @@ impl ApiHandler {
|
||||
.get("x-blob-filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
// Optional caller-supplied thumbnail (small, base64) — e.g. the mesh
|
||||
// chat's image-quality picker generates a tiny client-side preview so
|
||||
// a ContentRef receiver can render something before fetching the full
|
||||
// blob. Best-effort: a malformed header is just ignored, not fatal.
|
||||
let thumb_bytes = headers
|
||||
.get("x-blob-thumb")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|b64| {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
STANDARD.decode(b64).ok()
|
||||
});
|
||||
|
||||
let bytes = body.to_vec();
|
||||
// Uploads through /api/blob come from the node owner's session and
|
||||
@@ -55,7 +66,7 @@ impl ApiHandler {
|
||||
// pictures, banners). Store them public so `/blob/<cid>` serves
|
||||
// without a capability check — external Nostr clients fetching a
|
||||
// kind-0 `picture` URL have no cap and can't get one.
|
||||
match store.put(&bytes, &mime, filename, None, true).await {
|
||||
match store.put(&bytes, &mime, filename, thumb_bytes, true).await {
|
||||
Ok(meta) => {
|
||||
let exp =
|
||||
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||
|
||||
@@ -126,15 +126,15 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
/// Server-side fetch of the upstream app catalog so the browser can
|
||||
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
||||
/// load it without fighting CORS (upstream Gitea emits no ACAO) or
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||
/// list is derived from the operator's configured container registries
|
||||
/// so switching mirrors in Settings changes the App Store source too —
|
||||
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||
/// If registry config can't be loaded, falls back to the legacy
|
||||
/// hardcoded pair so the App Store still renders on nodes that haven't
|
||||
/// persisted a registry config yet. 15s total timeout.
|
||||
/// If registry config can't be loaded, falls back to the hardcoded OVH
|
||||
/// URL so the App Store still renders on nodes that haven't persisted
|
||||
/// a registry config yet. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||
let mut upstreams: Vec<String> = Vec::new();
|
||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||
@@ -155,10 +155,6 @@ impl ApiHandler {
|
||||
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
upstreams.push(
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
@@ -527,7 +523,7 @@ impl ApiHandler {
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
|
||||
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// the logged-in node owner can spin up fetches.
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
|
||||
@@ -39,6 +39,17 @@ impl ApiHandler {
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
// Subscribe BEFORE taking the initial snapshot. Messages are full
|
||||
// data dumps keyed by a monotonic revision, so a broadcast that
|
||||
// races the snapshot is at worst a harmless duplicate/newer dump
|
||||
// delivered right after — but subscribing after the snapshot send
|
||||
// (the old order) let any update in that window vanish forever,
|
||||
// since a tokio broadcast channel never delivers sends that
|
||||
// predate subscribe(). That silently stuck clients (e.g. a fresh
|
||||
// install's post-boot container scan) on a stale initial snapshot
|
||||
// until a full page reload opened a new connection past the race.
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
|
||||
let initial_msg = state_manager.get_initial_message().await;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
@@ -47,8 +58,6 @@ impl ApiHandler {
|
||||
}
|
||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||
}
|
||||
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
|
||||
@@ -326,75 +326,93 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Get all fleet nodes' latest reports.
|
||||
/// Reads all {node_id}.json files from telemetry-fleet/ (excluding *-history.json).
|
||||
///
|
||||
/// Primary source: TRUSTED federated nodes from nodes.json — their
|
||||
/// `last_state` snapshot (kept fresh by federation state-sync) already
|
||||
/// carries everything the Fleet UI renders. Observer ("peer") and
|
||||
/// Untrusted nodes are deliberately excluded from Fleet.
|
||||
///
|
||||
/// Secondary source: telemetry-fleet/*.json collector reports (opt-in
|
||||
/// anonymous telemetry, includes this node's own report) — merged in for
|
||||
/// back-compat with nodes that push telemetry but aren't federated.
|
||||
pub(super) async fn handle_telemetry_fleet_status(&self) -> Result<serde_json::Value> {
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if !fleet_dir.exists() {
|
||||
return Ok(serde_json::json!({ "nodes": [] }));
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// ── Trusted federation nodes ─────────────────────────────────────
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for n in fed_nodes
|
||||
.iter()
|
||||
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
|
||||
{
|
||||
let state = n.last_state.as_ref();
|
||||
let pct = |used: Option<u64>, total: Option<u64>| -> serde_json::Value {
|
||||
match (used, total) {
|
||||
(Some(u), Some(t)) if t > 0 => {
|
||||
serde_json::json!((u as f64 / t as f64 * 100.0).round())
|
||||
}
|
||||
_ => serde_json::json!(0),
|
||||
}
|
||||
};
|
||||
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
|
||||
let reported_at = state
|
||||
.map(|s| s.timestamp.clone())
|
||||
.or_else(|| n.last_seen.clone())
|
||||
.unwrap_or_else(|| n.added_at.clone());
|
||||
|
||||
let mut report = serde_json::json!({
|
||||
"node_id": n.did,
|
||||
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
|
||||
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0),
|
||||
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
|
||||
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
|
||||
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
|
||||
"container_count": apps.len(),
|
||||
"running_count": apps.iter().filter(|a| a.status == "running").count(),
|
||||
"federation_peers": state.map(|s| s.federated_peers.len()).unwrap_or(0),
|
||||
"containers": apps.iter().map(|a| serde_json::json!({
|
||||
"id": a.id,
|
||||
"state": a.status,
|
||||
"version": a.version.clone().unwrap_or_default(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"reported_at": reported_at,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"source": "federation",
|
||||
});
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
}
|
||||
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
// ── Opt-in telemetry collector reports ───────────────────────────
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if fleet_dir.exists() {
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Skip history files and non-JSON files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Skip history files and non-JSON files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(mut report) => {
|
||||
// Compute online/offline status from reported_at
|
||||
let is_online = report
|
||||
.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
age.num_minutes() < 30
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
// Compute human-readable last_seen
|
||||
let last_seen = report
|
||||
.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
let mins = age.num_minutes();
|
||||
if mins < 1 {
|
||||
"just now".to_string()
|
||||
} else if mins < 60 {
|
||||
format!("{}m ago", mins)
|
||||
} else if mins < 1440 {
|
||||
format!("{}h ago", mins / 60)
|
||||
} else {
|
||||
format!("{}d ago", mins / 1440)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if let Some(obj) = report.as_object_mut() {
|
||||
obj.insert("online".to_string(), serde_json::json!(is_online));
|
||||
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||
}
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Failed to read fleet report");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Failed to read fleet report");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,3 +549,40 @@ fn local_server_url(host_ip: &str) -> Option<String> {
|
||||
Some(format!("https://{host_ip}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp a fleet report with computed `online` and human-readable `last_seen`
|
||||
/// derived from its `reported_at` timestamp (online = reported <30min ago).
|
||||
fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
let reported = report
|
||||
.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok());
|
||||
|
||||
let is_online = reported
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
age.num_minutes() < 30
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let last_seen = reported
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
let mins = age.num_minutes();
|
||||
if mins < 1 {
|
||||
"just now".to_string()
|
||||
} else if mins < 60 {
|
||||
format!("{}m ago", mins)
|
||||
} else if mins < 1440 {
|
||||
format!("{}h ago", mins / 60)
|
||||
} else {
|
||||
format!("{}d ago", mins / 1440)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if let Some(obj) = report.as_object_mut() {
|
||||
obj.insert("online".to_string(), serde_json::json!(is_online));
|
||||
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
|
||||
use super::RpcHandler;
|
||||
#[cfg(debug_assertions)]
|
||||
use super::DEV_DEFAULT_PASSWORD;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -14,7 +16,10 @@ impl RpcHandler {
|
||||
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if !is_setup {
|
||||
// Dev mode: allow default password so UI can log in without running setup
|
||||
// Dev BUILDS only: allow the default password so the UI can log
|
||||
// in without running setup. cfg-gated so no release binary can
|
||||
// carry the bypass, whatever its runtime config says.
|
||||
#[cfg(debug_assertions)]
|
||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||
tracing::info!("[onboarding] login via dev default password");
|
||||
return Ok(serde_json::Value::Null);
|
||||
@@ -141,6 +146,19 @@ impl RpcHandler {
|
||||
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
tracing::info!("[onboarding] user setup complete");
|
||||
|
||||
// Persist the pending onboarding seed as the encrypted backup now that
|
||||
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||
// Best-effort: a failure here must not break password setup.
|
||||
match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await {
|
||||
Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"),
|
||||
Ok(false) => tracing::info!(
|
||||
"[onboarding] no pending mnemonic to back up (restored earlier or legacy node)"
|
||||
),
|
||||
Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"),
|
||||
}
|
||||
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
|
||||
@@ -862,7 +862,9 @@ async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
|
||||
let onion = onion.trim().trim_end_matches('/').to_string();
|
||||
if !onion.is_empty() {
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
let _ = save_relay_state(data_dir, state).await;
|
||||
if let Err(e) = save_relay_state(data_dir, state).await {
|
||||
tracing::warn!("Failed to persist relay tor endpoint: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,7 @@ impl RpcHandler {
|
||||
// launch_port_reachable() below would otherwise upgrade an exited backend
|
||||
// back to "running". The reconcile guard keeps these backends down, so the
|
||||
// marker is authoritative here.
|
||||
let user_stopped =
|
||||
crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
||||
let mut containers = Vec::with_capacity(data.package_data.len());
|
||||
for (id, pkg) in &data.package_data {
|
||||
|
||||
@@ -267,13 +267,16 @@ impl RpcHandler {
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@@ -348,13 +351,16 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -429,11 +435,15 @@ impl RpcHandler {
|
||||
},
|
||||
Some("fedimint") => match mint_fedimint().await {
|
||||
Ok((notes, fed)) => {
|
||||
tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}");
|
||||
tracing::info!(
|
||||
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
|
||||
);
|
||||
(notes, "fedimint")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}");
|
||||
tracing::warn!(
|
||||
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
|
||||
);
|
||||
return Ok(serde_json::json!({ "error": format!(
|
||||
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
||||
Fund it, or choose Cashu."
|
||||
@@ -457,7 +467,9 @@ impl RpcHandler {
|
||||
},
|
||||
},
|
||||
};
|
||||
tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash");
|
||||
tracing::info!(
|
||||
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
||||
);
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
@@ -491,13 +503,16 @@ impl RpcHandler {
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
@@ -757,13 +772,16 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
@@ -945,13 +963,16 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
@@ -1013,13 +1034,16 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -57,6 +57,8 @@ impl RpcHandler {
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||
"package.versions" => self.handle_package_versions(params).await,
|
||||
"package.set-config" => self.clone().handle_package_set_config(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
@@ -129,9 +131,13 @@ impl RpcHandler {
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
"lnd.lightning-history" => self.handle_lnd_lightning_history().await,
|
||||
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
|
||||
"lnd.seed-backup-status" => self.handle_lnd_seed_backup_status().await,
|
||||
"lnd.seed-reveal" => self.handle_lnd_seed_reveal(params).await,
|
||||
"lnd.seed-backup-ack" => self.handle_lnd_seed_backup_ack().await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
@@ -221,6 +227,7 @@ impl RpcHandler {
|
||||
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||
"network.scan-wifi" => self.handle_network_scan_wifi().await,
|
||||
"network.configure-wifi" => self.handle_network_configure_wifi(params).await,
|
||||
"network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await,
|
||||
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
|
||||
"network.dns-status" => self.handle_network_dns_status().await,
|
||||
"network.configure-dns" => self.handle_network_configure_dns(params).await,
|
||||
@@ -228,6 +235,13 @@ impl RpcHandler {
|
||||
"router.info" => self.handle_router_info().await,
|
||||
"router.configure" => self.handle_router_configure(params).await,
|
||||
|
||||
// OpenWrt / TollGate
|
||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
|
||||
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||
@@ -244,6 +258,17 @@ impl RpcHandler {
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
|
||||
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
|
||||
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
|
||||
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
|
||||
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
|
||||
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
|
||||
"wallet.ark-history" => self.handle_wallet_ark_history().await,
|
||||
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
@@ -312,7 +337,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// Federation
|
||||
"federation.invite" => self.handle_federation_invite().await,
|
||||
"federation.invite" => self.handle_federation_invite(params).await,
|
||||
"federation.join" => self.handle_federation_join(params).await,
|
||||
"federation.list-nodes" => self.handle_federation_list_nodes().await,
|
||||
"federation.remove-node" => self.handle_federation_remove_node(params).await,
|
||||
@@ -364,6 +389,7 @@ impl RpcHandler {
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
|
||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
|
||||
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||
@@ -416,8 +442,10 @@ impl RpcHandler {
|
||||
|
||||
// Server settings
|
||||
"server.set-name" => self.handle_server_set_name(params).await,
|
||||
"server.set-location" => self.handle_server_set_location(params).await,
|
||||
|
||||
// System monitoring
|
||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||
"system.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
"system.temperature" => self.handle_system_temperature().await,
|
||||
|
||||
@@ -53,7 +53,24 @@ impl RpcHandler {
|
||||
|
||||
impl RpcHandler {
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
|
||||
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let trust_level = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("trust_level"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
TrustLevel::parse(s).ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)")
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
@@ -72,14 +89,16 @@ impl RpcHandler {
|
||||
&onion,
|
||||
&pubkey,
|
||||
fips_npub.as_deref(),
|
||||
trust_level,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite");
|
||||
info!(did = %did, trust = %trust_level, fips_advertised = fips_npub.is_some(), "Generated federation invite");
|
||||
Ok(serde_json::json!({
|
||||
"code": code,
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"trust_level": trust_level.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -454,6 +473,12 @@ impl RpcHandler {
|
||||
.flatten(),
|
||||
};
|
||||
|
||||
let shared_location = if data.server_info.share_location {
|
||||
data.server_info.lat.zip(data.server_info.lon)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = federation::build_local_state(
|
||||
apps,
|
||||
0.0,
|
||||
@@ -467,6 +492,7 @@ impl RpcHandler {
|
||||
nostr_npub,
|
||||
own_fips_npub,
|
||||
&federated_peers,
|
||||
shared_location,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
@@ -504,6 +530,36 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Resolve the trust level granted by this join. Authoritative source:
|
||||
// the acceptor echoes the invite's random token, which we match against
|
||||
// OUR stored outgoing invites — the level we minted the code with wins.
|
||||
// Fallback: the peer's (unsigned) "trust" claim, honored only as a
|
||||
// DOWNGRADE from Trusted so it can never escalate. Legacy peers send
|
||||
// neither → Trusted, matching pre-threading behavior.
|
||||
let claimed_trust = params
|
||||
.get("trust")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(TrustLevel::parse)
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
let invite_trust = match params.get("invite_token").and_then(|v| v.as_str()) {
|
||||
Some(token) => federation::load_invites(&self.config.data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|invites| {
|
||||
invites.outgoing.iter().find_map(|inv| {
|
||||
federation::parse_invite(&inv.code)
|
||||
.ok()
|
||||
.filter(|p| p.token == token)
|
||||
.map(|_| inv.trust_level)
|
||||
})
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let granted_trust = match invite_trust {
|
||||
Some(level) => level,
|
||||
None => TrustLevel::Trusted.min(claimed_trust),
|
||||
};
|
||||
|
||||
// Reject self-peering. If somehow our own did / onion / pubkey
|
||||
// comes back at us (misconfigured invite, gossip loop), adding
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
@@ -596,7 +652,7 @@ impl RpcHandler {
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: incoming_name.clone(),
|
||||
trust_level: TrustLevel::Trusted,
|
||||
trust_level: granted_trust,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
@@ -606,7 +662,7 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
info!(peer_did = %did, "Peer joined our federation");
|
||||
info!(peer_did = %did, trust = %granted_trust, "Peer joined our federation");
|
||||
|
||||
// Mirror into mesh state so the inbound peer is addressable from
|
||||
// the chat UI without waiting for the next mesh restart.
|
||||
@@ -1039,12 +1095,16 @@ impl RpcHandler {
|
||||
// ciphertext below.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||
// Discovery/connection-request approvals admit the requester as
|
||||
// Observer — the invite itself now carries that level, so both
|
||||
// sides converge on Observer without post-hoc demotion.
|
||||
let invite_code = federation::create_invite(
|
||||
&self.config.data_dir,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
TrustLevel::Observer,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -298,8 +298,10 @@ impl RpcHandler {
|
||||
Ok(node) => {
|
||||
// Approved-by-them: their box already has us as Observer
|
||||
// (their approval handler added us under that trust level
|
||||
// before sending the invite). Demote our local entry to
|
||||
// Observer too — accept_invite hardcodes Trusted, but the
|
||||
// before sending the invite). Discovery invites are now
|
||||
// minted with trust=observer, so accept_invite already
|
||||
// lands on Observer; keep this explicit demotion as a
|
||||
// safety net for legacy Trusted-only invite codes — the
|
||||
// discovery flow should never auto-trust.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
|
||||
@@ -18,6 +18,24 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "networks": networks }))
|
||||
}
|
||||
|
||||
/// network.set-wifi-radio — turn the wifi adapter fully on or off (not just
|
||||
/// disconnect from a network). Params: `{ "enabled": bool }`.
|
||||
pub(super) async fn handle_network_set_wifi_radio(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?;
|
||||
|
||||
tracing::info!(enabled, "Setting wifi radio state");
|
||||
set_wifi_radio(enabled).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// network.configure-wifi — connect to a WiFi network.
|
||||
pub(super) async fn handle_network_configure_wifi(
|
||||
&self,
|
||||
@@ -327,6 +345,27 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||
fields
|
||||
}
|
||||
|
||||
/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not
|
||||
/// just disconnecting from the current network — the adapter stops scanning/
|
||||
/// associating entirely until switched back on).
|
||||
async fn set_wifi_radio(enabled: bool) -> Result<()> {
|
||||
let state = if enabled { "on" } else { "off" };
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["radio", "wifi", state])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli radio wifi")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli radio wifi {} failed: {}",
|
||||
state,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to a WiFi network using nmcli.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
|
||||
@@ -198,11 +198,49 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
||||
let private = params
|
||||
.get("private")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
amount = amount,
|
||||
private = private,
|
||||
"Opening Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// First connect to the peer if an address is provided
|
||||
// First connect to the peer if an address is provided.
|
||||
// perm=false makes LND connect synchronously, so the peer is online
|
||||
// (or we get a real error) before we attempt the channel open.
|
||||
// perm=true queues the connection in the background and returns
|
||||
// immediately, which makes the subsequent open race and fail with
|
||||
// "peer is not online".
|
||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||
// Validate peer address format (host:port)
|
||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||
@@ -210,20 +248,43 @@ impl RpcHandler {
|
||||
}
|
||||
let connect_body = serde_json::json!({
|
||||
"addr": { "pubkey": pubkey, "host": addr },
|
||||
"perm": true
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
let _ = client
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await;
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if !connect_resp.status().is_success() {
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if !msg.contains("already connected") {
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let open_body = serde_json::json!({
|
||||
let mut open_body = serde_json::json!({
|
||||
"node_pubkey_string": pubkey,
|
||||
"local_funding_amount": amount.to_string(),
|
||||
"private": private,
|
||||
});
|
||||
if let Some(tc) = target_conf {
|
||||
open_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
open_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod channels;
|
||||
mod info;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
|
||||
@@ -206,4 +206,116 @@ impl RpcHandler {
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unified Lightning history: settled invoices (incoming) + succeeded
|
||||
/// payments (outgoing), normalized to the wallet-transaction shape the
|
||||
/// UI already renders. On-chain history stays in lnd.gettransactions.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_lightning_history(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
|
||||
fn field_i64(v: &serde_json::Value, key: &str) -> i64 {
|
||||
v.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| v.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Outgoing: succeeded payments only (include_incomplete=false)
|
||||
let payments_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=false&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if payments_resp.status().is_success() {
|
||||
let body: serde_json::Value = payments_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
for p in body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let amount = field_i64(p, "value_sat");
|
||||
if amount == 0 {
|
||||
continue;
|
||||
}
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": p.get("payment_hash").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"amount_sats": amount,
|
||||
"direction": "outgoing",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(p, "creation_date"),
|
||||
"total_fees": field_i64(p, "fee_sat"),
|
||||
"dest_addresses": [],
|
||||
"label": "",
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming: settled invoices only
|
||||
let invoices_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/invoices?num_max_invoices=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if invoices_resp.status().is_success() {
|
||||
let body: serde_json::Value = invoices_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoices response")?;
|
||||
for inv in body
|
||||
.get("invoices")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let settled = inv.get("state").and_then(|v| v.as_str()) == Some("SETTLED")
|
||||
|| inv.get("settled").and_then(|v| v.as_bool()) == Some(true);
|
||||
if !settled {
|
||||
continue;
|
||||
}
|
||||
// r_hash arrives base64 from REST; the UI shows hex
|
||||
let r_hash_hex = inv
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": r_hash_hex,
|
||||
"amount_sats": field_i64(inv, "amt_paid_sat"),
|
||||
"direction": "incoming",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(inv, "settle_date"),
|
||||
"total_fees": 0,
|
||||
"dest_addresses": [],
|
||||
"label": inv.get("memo").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Encrypted LND aezeed backup: status, reveal, and acknowledgment.
|
||||
//!
|
||||
//! The aezeed is captured once at wallet-init time (see
|
||||
//! `crate::container::lnd::persist_aezeed_backup`) and stored under
|
||||
//! `identity/lnd_aezeed.enc`, encrypted with the per-node wallet secret.
|
||||
//! Reveal is gated like `seed.reveal`: authenticated session + password
|
||||
//! re-verification + TOTP when enabled.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Whether an encrypted aezeed backup exists and whether the user has
|
||||
/// confirmed writing it down. Drives the first-launch backup prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let data_dir = &self.config.data_dir;
|
||||
Ok(serde_json::json!({
|
||||
"available": crate::seed::lnd_aezeed_exists(data_dir),
|
||||
"acknowledged": crate::seed::lnd_aezeed_acknowledged(data_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the Lightning wallet's 24 aezeed words. Same gating as
|
||||
/// `seed.reveal`; the words are returned to the caller only, never logged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
if !crate::seed::lnd_aezeed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"No Lightning seed backup exists on this node. It is captured \
|
||||
automatically when the Lightning wallet is first created."
|
||||
);
|
||||
}
|
||||
|
||||
let mut password = self
|
||||
.verify_reveal_auth(¶ms, "the Lightning seed")
|
||||
.await?;
|
||||
password.zeroize();
|
||||
|
||||
// The backup is encrypted with the per-node wallet secret (the boot
|
||||
// path has no user password), so re-auth above is the actual gate.
|
||||
let mut node_secret = crate::container::lnd::wallet_password_if_exists()
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved Lightning seed — the per-node \
|
||||
wallet secret is missing"
|
||||
)
|
||||
})?;
|
||||
let words =
|
||||
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
|
||||
node_secret.zeroize();
|
||||
let words = words
|
||||
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
|
||||
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
|
||||
/// Record that the user confirmed backing up the Lightning seed, which
|
||||
/// dismisses the first-launch prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_ack(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
crate::seed::mark_lnd_aezeed_acknowledged(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "acknowledged": true }))
|
||||
}
|
||||
}
|
||||
@@ -816,7 +816,6 @@ impl RpcHandler {
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
@@ -825,9 +824,45 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
// InitWallet does NOT accept raw entropy — `seed_entropy` is a GenSeed
|
||||
// field. Posting it to /v1/initwallet meant the wallet was never
|
||||
// actually derived from the Archipelago seed. GenSeed(entropy) returns
|
||||
// the deterministic aezeed words, which InitWallet then consumes — and
|
||||
// which we capture for the encrypted seed backup (lnd.seed-reveal).
|
||||
let genseed_resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/genseed"))
|
||||
.query(&[("seed_entropy", entropy_b64.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.context("LND genseed request failed — is LND running and uninitialized?")?;
|
||||
let genseed_status = genseed_resp.status();
|
||||
let genseed_body: serde_json::Value = genseed_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse genseed response")?;
|
||||
if !genseed_status.is_success() {
|
||||
let msg = genseed_body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("LND seed generation failed: {}", msg));
|
||||
}
|
||||
let cipher_seed_mnemonic: Vec<String> = genseed_body
|
||||
.get("cipher_seed_mnemonic")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|w| w.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if cipher_seed_mnemonic.is_empty() {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let init_body = serde_json::json!({
|
||||
"wallet_password": wallet_password_b64,
|
||||
"seed_entropy": entropy_b64,
|
||||
"cipher_seed_mnemonic": cipher_seed_mnemonic,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
@@ -851,6 +886,13 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
||||
}
|
||||
|
||||
crate::container::lnd::persist_aezeed_backup(
|
||||
&self.config.data_dir,
|
||||
&cipher_seed_mnemonic,
|
||||
&node_wallet_pw,
|
||||
)
|
||||
.await;
|
||||
|
||||
info!("LND wallet initialized from master seed entropy");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -19,7 +19,10 @@ impl RpcHandler {
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
(svc.assistant_config().await, svc.assistant_denied_askers().await)
|
||||
(
|
||||
svc.assistant_config().await,
|
||||
svc.assistant_denied_askers().await,
|
||||
)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
|
||||
@@ -86,6 +86,29 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.reboot-radio — Reboot the locally-connected radio firmware to
|
||||
/// recover a wedged / RX-deaf radio. Optional `seconds` delay (default 2).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_reboot_radio(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let seconds = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("seconds"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(2);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.reboot_radio(seconds).await?;
|
||||
info!(seconds, "Mesh radio reboot requested via RPC");
|
||||
|
||||
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||
&self,
|
||||
@@ -122,6 +145,32 @@ impl RpcHandler {
|
||||
{
|
||||
config.receive_block_headers = receive;
|
||||
}
|
||||
// LoRa region (Meshtastic): validated against the driver's region
|
||||
// table so a typo can't be persisted and silently ignored on connect.
|
||||
// Empty string clears the setting (radio keeps/uses its own region).
|
||||
if let Some(region) = params.get("lora_region").and_then(|v| v.as_str()) {
|
||||
let trimmed = region.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||
config.lora_region = None;
|
||||
} else if mesh::meshtastic_region_is_valid(trimmed) {
|
||||
config.lora_region = Some(trimmed.to_uppercase());
|
||||
} else {
|
||||
anyhow::bail!("Unknown LoRa region: {trimmed}");
|
||||
}
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
config.device_kind = match kind.trim().to_lowercase().as_str() {
|
||||
"" | "auto" => None,
|
||||
"meshcore" => Some(mesh::types::DeviceType::Meshcore),
|
||||
"meshtastic" => Some(mesh::types::DeviceType::Meshtastic),
|
||||
"reticulum" | "rnode" => Some(mesh::types::DeviceType::Reticulum),
|
||||
other => anyhow::bail!(
|
||||
"Unknown device_kind: {other} (expected auto|meshcore|meshtastic|reticulum)"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
@@ -138,6 +187,8 @@ impl RpcHandler {
|
||||
"device_path": config.device_path,
|
||||
"announce_block_headers": config.announce_block_headers,
|
||||
"receive_block_headers": config.receive_block_headers,
|
||||
"lora_region": config.lora_region,
|
||||
"device_kind": config.device_kind.map(|k| k.to_string()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::warn;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
@@ -36,6 +37,39 @@ impl RpcHandler {
|
||||
"receive_block_headers".into(),
|
||||
config.receive_block_headers.into(),
|
||||
);
|
||||
// Persisted config values the settings UI edits (distinct from the
|
||||
// live radio-reported `region`): the configured LoRa region and
|
||||
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
|
||||
obj.insert("lora_region".into(), config.lora_region.clone().into());
|
||||
obj.insert(
|
||||
"device_kind".into(),
|
||||
config
|
||||
.device_kind
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.into(),
|
||||
);
|
||||
// USB identity per detected port so the setup modal can show the
|
||||
// actual board (product string on native-USB boards, vid:pid as
|
||||
// the fallback for bridge chips).
|
||||
obj.insert(
|
||||
"detected_device_info".into(),
|
||||
serde_json::to_value(mesh::detect_devices_info().await).unwrap_or_default(),
|
||||
);
|
||||
// Raw serial-device presence, in BOTH branches. MeshStatus has no
|
||||
// such field, so while the service was running the UI couldn't
|
||||
// tell "no radio plugged in" from "radio present but the session
|
||||
// can't open it yet" — both looked like device_connected=false.
|
||||
if !obj.contains_key("detected_devices") {
|
||||
let devices = mesh::detect_devices().await;
|
||||
obj.insert("device_present".into(), (!devices.is_empty()).into());
|
||||
obj.insert("detected_devices".into(), devices.into());
|
||||
} else {
|
||||
let present = obj
|
||||
.get("detected_devices")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|a| !a.is_empty());
|
||||
obj.insert("device_present".into(), present.into());
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -283,7 +317,9 @@ impl RpcHandler {
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
set.clear();
|
||||
}
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||
if let Err(e) = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await {
|
||||
warn!("Failed to persist cleared radio-contact blocklist: {e:#}");
|
||||
}
|
||||
|
||||
// Actually DELETE each radio contact from the firmware table (via
|
||||
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::mesh::message_types::{
|
||||
Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MeshMessageType,
|
||||
MessageKey, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope,
|
||||
};
|
||||
use crate::mesh::types::radio_transport_label;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
@@ -391,9 +392,24 @@ impl RpcHandler {
|
||||
|
||||
// Hard ceiling matching the chunked-send capacity (~20 chunks * 152
|
||||
// b64 chars after MCIIXXTT framing). Anything larger must go via
|
||||
// ContentRef over Tor.
|
||||
// ContentRef over Tor — UNLESS the active device is Reticulum, which
|
||||
// can carry up to RETICULUM_RESOURCE_MAX directly over LoRa via a
|
||||
// native RNS Resource transfer (keep this ceiling in sync with
|
||||
// `mesh.transport-advice`'s `"resource-mesh"` tier, the source of
|
||||
// truth the frontend consults before ever reaching this size).
|
||||
const INLINE_HARD_MAX: usize = 2300;
|
||||
if bytes.len() > INLINE_HARD_MAX {
|
||||
const RETICULUM_RESOURCE_MAX: usize = 2 * 1024 * 1024;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let device_type = svc.shared_state().status.read().await.device_type;
|
||||
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
|
||||
|
||||
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
|
||||
anyhow::bail!(
|
||||
"Payload {} bytes exceeds inline max {} — use mesh.send-content (ContentRef) instead",
|
||||
bytes.len(),
|
||||
@@ -414,22 +430,6 @@ impl RpcHandler {
|
||||
.put(&bytes, &mime, filename.clone(), None, false)
|
||||
.await?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let content = ContentInlinePayload {
|
||||
mime: mime.clone(),
|
||||
filename: filename.clone(),
|
||||
caption: caption.clone(),
|
||||
bytes,
|
||||
};
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
let payload = message_types::encode_payload(&content)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let display = match (&filename, &caption) {
|
||||
(Some(f), Some(c)) => format!("📎 {} — {}", f, c),
|
||||
(Some(f), None) => format!("📎 {}", f),
|
||||
@@ -437,7 +437,8 @@ impl RpcHandler {
|
||||
(None, None) => format!("📎 {} ({} bytes)", mime, meta.size),
|
||||
};
|
||||
// Render as a content_ref card on the sender side (UI already knows
|
||||
// how to draw it from cid + mime + filename + size).
|
||||
// how to draw it from cid + mime + filename + size) regardless of
|
||||
// which wire format actually goes out — this is a local-only mirror.
|
||||
let typed_json = serde_json::json!({
|
||||
"cid": meta.cid,
|
||||
"size": meta.size,
|
||||
@@ -446,22 +447,68 @@ impl RpcHandler {
|
||||
"caption": caption,
|
||||
"inline": true,
|
||||
});
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
|
||||
let msg = svc
|
||||
.send_typed_wire(
|
||||
// A stock (non-archy) peer can't decode our typed-envelope wire
|
||||
// format — send images to them via LXMF's native FIELD_IMAGE
|
||||
// instead, so they actually see the photo (Sideband/NomadNet).
|
||||
let is_archy = svc.is_archy_peer(contact_id).await;
|
||||
let native_image = !is_archy
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& mime.starts_with("image/");
|
||||
|
||||
let msg = if native_image {
|
||||
svc.send_native_image(contact_id, &mime, bytes, caption.clone())
|
||||
.await?;
|
||||
svc.record_sent_typed(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
Some(radio_transport_label(device_type).to_string()),
|
||||
true, // Reticulum/LXMF is unconditionally E2E on every send
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
} else {
|
||||
let content = ContentInlinePayload {
|
||||
mime: mime.clone(),
|
||||
filename: filename.clone(),
|
||||
caption: caption.clone(),
|
||||
bytes,
|
||||
};
|
||||
let payload = message_types::encode_payload(&content)?;
|
||||
let envelope =
|
||||
TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
if use_resource_transfer {
|
||||
svc.send_content_resource(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
contact_id,
|
||||
size = meta.size,
|
||||
cid = %meta.cid,
|
||||
via_resource = use_resource_transfer,
|
||||
"Sent content_inline over mesh"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
@@ -492,8 +539,19 @@ impl RpcHandler {
|
||||
// Knobs — keep in sync with the frontend modal copy.
|
||||
const MESH_AUTO_MAX: u64 = 1024;
|
||||
const MESH_HARD_MAX: u64 = 2300;
|
||||
// Reticulum-only: above the small inline-chunk cap, a real RNS Resource
|
||||
// transfer can still carry the payload directly over LoRa (native
|
||||
// chunked transfer with retries) instead of falling back to Tor. Capped
|
||||
// well under TOR_LARGE_WARN to keep worst-case LoRa transfer time
|
||||
// bounded — comfortably covers the HIGH image preset (512KB target).
|
||||
const RETICULUM_RESOURCE_MAX: u64 = 2 * 1024 * 1024;
|
||||
const TOR_LARGE_WARN: u64 = 5 * 1024 * 1024;
|
||||
const LORA_BYTES_PER_SEC: u64 = 50;
|
||||
// Meshcore/Meshtastic effective LoRa throughput after retries/FEC is much
|
||||
// lower than the raw radio bitrate. Reticulum's RNodeInterface reports its
|
||||
// real bitrate (e.g. ~3125 bps ≈ 390 B/s observed live), so estimates for it
|
||||
// would be wildly pessimistic at the generic 50 B/s figure.
|
||||
const LORA_BYTES_PER_SEC_DEFAULT: u64 = 50;
|
||||
const LORA_BYTES_PER_SEC_RETICULUM: u64 = 390;
|
||||
|
||||
// Resolve peer Tor reachability via federation node list.
|
||||
let service = self.mesh_service.read().await;
|
||||
@@ -501,6 +559,12 @@ impl RpcHandler {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let device_type = state.status.read().await.device_type;
|
||||
let lora_bytes_per_sec = if device_type == crate::mesh::types::DeviceType::Reticulum {
|
||||
LORA_BYTES_PER_SEC_RETICULUM
|
||||
} else {
|
||||
LORA_BYTES_PER_SEC_DEFAULT
|
||||
};
|
||||
let (peer_pubkey_hex, peer_did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&contact_id) {
|
||||
@@ -520,8 +584,9 @@ impl RpcHandler {
|
||||
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||
.unwrap_or(false);
|
||||
|
||||
let est_seconds = (size.saturating_add(LORA_BYTES_PER_SEC - 1) / LORA_BYTES_PER_SEC).max(1);
|
||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
|
||||
let (tier, reason) = if size <= MESH_AUTO_MAX {
|
||||
("auto-mesh", "Small enough to send inline over mesh")
|
||||
} else if size <= MESH_HARD_MAX {
|
||||
@@ -530,6 +595,11 @@ impl RpcHandler {
|
||||
} else {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
(
|
||||
"resource-mesh",
|
||||
"Sending directly over LoRa via a Reticulum resource transfer",
|
||||
)
|
||||
} else if size <= TOR_LARGE_WARN {
|
||||
if has_tor {
|
||||
("tor-only", "Too large for mesh — Tor only")
|
||||
@@ -674,18 +744,6 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cid"))?
|
||||
.to_string();
|
||||
let sender_onion = params["sender_onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let cap_token = params["cap_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
|
||||
.to_string();
|
||||
let cap_exp = params["cap_exp"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
|
||||
let mime_hint = params["mime"]
|
||||
.as_str()
|
||||
.unwrap_or("application/octet-stream")
|
||||
@@ -709,7 +767,12 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Short-circuit if we already hold the blob — still issue a fresh
|
||||
// self-cap so the UI gets a displayable local URL.
|
||||
// self-cap so the UI gets a displayable local URL. Checked BEFORE the
|
||||
// sender_onion/cap_token/cap_exp params are required below: an inline
|
||||
// ContentInline attachment (mesh.send-content-inline) is written to
|
||||
// our own BlobStore the moment it's received/sent (dispatch.rs), so
|
||||
// its typed_payload never carries those fields at all — only a
|
||||
// ContentRef fetched from a remote peer needs them.
|
||||
if blob_store.has(&cid).await {
|
||||
let local_exp = (chrono::Utc::now().timestamp() as u64) + DEFAULT_CAP_TTL_SECS;
|
||||
let local_cap = blob_store.issue_capability(&cid, &self_pubkey_hex, local_exp);
|
||||
@@ -725,6 +788,19 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
let sender_onion = params["sender_onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let cap_token = params["cap_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
|
||||
.to_string();
|
||||
let cap_exp = params["cap_exp"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
|
||||
|
||||
// Reach the sender: FIPS preferred when the sender is federated
|
||||
// and has advertised a FIPS npub, Tor fallback otherwise.
|
||||
// Cap/exp/peer in the query string match what the sender signed in
|
||||
@@ -860,6 +936,15 @@ impl RpcHandler {
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
// Read receipts are fired automatically just by viewing a chat (no
|
||||
// explicit user action), unlike every other typed send here — so a
|
||||
// stock (non-archy) peer that can't decode a TypedEnvelope at all
|
||||
// (e.g. a phone running plain Sideband) would otherwise get a raw
|
||||
// control envelope shoved at it the moment its message is viewed,
|
||||
// surfacing as garbage text right after whatever it just sent.
|
||||
if !svc.is_archy_peer(contact_id).await {
|
||||
return Ok(serde_json::json!({ "sent": false, "reason": "not an archy peer" }));
|
||||
}
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
let payload = message_types::encode_payload(&receipt)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(seq);
|
||||
|
||||
@@ -61,9 +61,47 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Session",
|
||||
"Failed to pull",
|
||||
"Failed to start",
|
||||
"Failed to open channel",
|
||||
"Failed to close channel",
|
||||
"Failed to connect to peer",
|
||||
// App-install dependency errors (package/dependencies.rs) — masking
|
||||
// these left users retrying installs blind ("LND install failed" on a
|
||||
// fresh node was really "Bitcoin Knots isn't running yet")
|
||||
"LND requires",
|
||||
"ElectrumX requires",
|
||||
"BTCPay Server requires",
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
"No space left",
|
||||
"Not enough flash",
|
||||
"Not enough space",
|
||||
"TollGate installation failed",
|
||||
"No pre-built TollGate",
|
||||
"opkg not found",
|
||||
"apk update failed",
|
||||
"No wireless interface",
|
||||
"No wireless radio",
|
||||
"WiFi radio enabled but",
|
||||
"Missing required field",
|
||||
// seed.reveal / auth flows — user-actionable, no internals to leak.
|
||||
// Without these the sanitizer collapsed every reveal failure into
|
||||
// "Operation failed. Check server logs." (which isn't even a crash).
|
||||
"Incorrect",
|
||||
"This node has no encrypted seed",
|
||||
"No Lightning seed backup",
|
||||
"Could not decrypt the saved Lightning seed",
|
||||
"A 2FA code is required",
|
||||
"2FA is enabled but",
|
||||
"Could not decrypt the saved seed",
|
||||
"Could not unlock 2FA",
|
||||
"No mnemonic available",
|
||||
"No pending seed generation",
|
||||
"Submitted words",
|
||||
"Already set up",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
@@ -83,6 +121,45 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Operation failed. Check server logs for details.".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sanitize_tests {
|
||||
use super::sanitize_error_message;
|
||||
|
||||
#[test]
|
||||
fn seed_reveal_errors_pass_through() {
|
||||
// Every user-actionable seed.reveal failure must reach the user —
|
||||
// masking them as "Check server logs" sent a real user hunting a
|
||||
// crash that never happened.
|
||||
for msg in [
|
||||
"Incorrect password",
|
||||
"This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.",
|
||||
"A 2FA code is required to reveal the recovery phrase",
|
||||
"2FA is enabled but no TOTP data found",
|
||||
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
|
||||
"Could not unlock 2FA with this password",
|
||||
"No mnemonic available. Generate or restore a seed first.",
|
||||
"No Lightning seed backup exists on this node. It is captured automatically when the Lightning wallet is first created.",
|
||||
"Could not decrypt the saved Lightning seed backup",
|
||||
"Submitted words do not match generated seed",
|
||||
"Already set up. Use auth.changePassword to change.",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
sanitize_error_message("thread panicked at src/foo.rs:42"),
|
||||
"Operation failed. Check server logs for details."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a CSRF token from the session token via HMAC.
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
@@ -116,13 +193,89 @@ pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<S
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||
pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||
/// The TCP peer address of the connection a request arrived on, injected
|
||||
/// into request extensions by the server accept loop.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PeerAddr(pub std::net::SocketAddr);
|
||||
|
||||
/// Extract the client IP for rate limiting.
|
||||
///
|
||||
/// `X-Real-IP`/`X-Forwarded-For` are only honored when the connection
|
||||
/// itself comes from loopback — i.e. from our local nginx, which sets
|
||||
/// `X-Real-IP $remote_addr`. On a direct connection (the FIPS peer
|
||||
/// listener, or anything that isn't the local proxy) the headers are
|
||||
/// client-supplied, so trusting them let an attacker rotate per-request
|
||||
/// "IPs" and defeat the login rate limiter; there we use the socket
|
||||
/// address instead.
|
||||
pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
|
||||
let socket_ip = parts.extensions.get::<PeerAddr>().map(|p| p.0.ip());
|
||||
match socket_ip {
|
||||
Some(ip) if ip.is_loopback() => forwarded_client_ip(&parts.headers).unwrap_or(ip),
|
||||
Some(ip) => ip,
|
||||
// No socket info recorded (shouldn't happen in the server path);
|
||||
// fall back to the pre-extension behavior.
|
||||
None => {
|
||||
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy-reported client IP, if a forwarded header carries one.
|
||||
fn forwarded_client_ip(headers: &hyper::HeaderMap) -> Option<IpAddr> {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.or_else(|| headers.get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_ip_tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
|
||||
let mut builder = hyper::Request::builder().uri("/rpc/v1");
|
||||
if let Some(ip) = real_ip {
|
||||
builder = builder.header("x-real-ip", ip);
|
||||
}
|
||||
let (mut parts, _) = builder.body(()).unwrap().into_parts();
|
||||
if let Some(addr) = peer {
|
||||
parts
|
||||
.extensions
|
||||
.insert(PeerAddr(addr.parse::<SocketAddr>().unwrap()));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_connection_trusts_forwarded_header() {
|
||||
// nginx on loopback forwards the real client IP — use it.
|
||||
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"192.168.1.50".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connection_ignores_spoofed_header() {
|
||||
// A direct (non-proxy) client rotating X-Real-IP per request must
|
||||
// still bucket under its socket address.
|
||||
let parts = parts_with(Some("203.0.113.9:9999"), Some("10.0.0.1"));
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"203.0.113.9".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_connection_without_header_uses_socket_ip() {
|
||||
let parts = parts_with(Some("127.0.0.1:5000"), None);
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
@@ -23,6 +24,7 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
@@ -53,6 +55,7 @@ use hyper::{Request, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error};
|
||||
|
||||
pub use middleware::PeerAddr;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
@@ -60,6 +63,9 @@ use middleware::{
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
/// Dev builds only — the pre-setup login bypass that reads this is
|
||||
/// cfg-gated out of release binaries.
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
|
||||
pub struct RpcHandler {
|
||||
@@ -368,7 +374,7 @@ impl RpcHandler {
|
||||
|
||||
// Rate limit login attempts
|
||||
if rpc_req.method == "auth.login" {
|
||||
let client_ip = extract_client_ip(&parts.headers);
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
if !self.login_rate_limiter.check(client_ip).await {
|
||||
return Ok(self.rate_limit_response());
|
||||
}
|
||||
@@ -376,7 +382,7 @@ impl RpcHandler {
|
||||
|
||||
// Rate limit sensitive endpoints
|
||||
{
|
||||
let client_ip = extract_client_ip(&parts.headers);
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
if !self
|
||||
.endpoint_rate_limiter
|
||||
.check(&rpc_req.method, client_ip)
|
||||
@@ -450,7 +456,7 @@ impl RpcHandler {
|
||||
let mut response = json_response(StatusCode::OK, &resp_body);
|
||||
|
||||
// Post-dispatch: set cookies for auth-related methods
|
||||
let client_ip = extract_client_ip(&parts.headers);
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
self.apply_auth_cookies(
|
||||
&rpc_req.method,
|
||||
&mut rpc_resp,
|
||||
|
||||
@@ -168,8 +168,10 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
// The web UI historically sent `request_id`; accept both spellings.
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
@@ -243,8 +245,10 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
// The web UI historically sent `request_id`; accept both spellings.
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::router as net_router;
|
||||
use anyhow::Result;
|
||||
use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
wan, wifi_scan,
|
||||
};
|
||||
|
||||
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
|
||||
const LOCAL_MINT_PORT: u16 = 3338;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Scan the local subnet for OpenWrt routers.
|
||||
///
|
||||
/// Params: `{ "subnet": "192.168.1.0", "prefix": 24,
|
||||
/// "ssh_user": "root", "ssh_password": "" }`
|
||||
pub(super) async fn handle_openwrt_scan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let subnet: [u8; 4] = parse_ipv4(
|
||||
p.get("subnet")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("192.168.1.0"),
|
||||
)?;
|
||||
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("root")
|
||||
.to_string();
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
|
||||
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
|
||||
|
||||
Ok(serde_json::json!({ "routers": ips }))
|
||||
}
|
||||
|
||||
/// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI.
|
||||
///
|
||||
/// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }`
|
||||
/// If params are omitted the saved `router_config.json` credentials are used.
|
||||
pub(super) async fn handle_openwrt_get_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
let host_from_params = p.get("host").and_then(|v| v.as_str()).is_some();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
// Persist the connection so other views (e.g. the Home dashboard's
|
||||
// Network tile) can poll `openwrt.get-status` with no params instead
|
||||
// of every caller needing to carry host/credentials around. Only do
|
||||
// this when the host actually came from params — otherwise every
|
||||
// no-args poll would re-save the same thing it just read.
|
||||
if host_from_params {
|
||||
let _ = net_router::configure_router(
|
||||
&self.config.data_dir,
|
||||
net_router::RouterType::OpenWrt,
|
||||
&host,
|
||||
None,
|
||||
Some(&ssh_user),
|
||||
Some(&ssh_password),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// System info
|
||||
let release = router
|
||||
.run_ok("cat /etc/openwrt_release")
|
||||
.unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
|
||||
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
|
||||
/// "mint_url": "<optional override>" }`
|
||||
///
|
||||
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
|
||||
/// mint that must be running as an Archy app before calling this endpoint.
|
||||
pub(super) async fn handle_openwrt_provision_tollgate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
|
||||
let mint_url = p
|
||||
.get("mint_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_mint_url)
|
||||
.to_string();
|
||||
|
||||
let config = TollGateConfig {
|
||||
ssid: "archipelago".to_string(),
|
||||
mint_url,
|
||||
price_sats: p.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(10),
|
||||
step_size_ms: p
|
||||
.get("step_size_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60_000),
|
||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
};
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
tollgate::provision(&router, &config).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"host": host,
|
||||
"ssid": config.ssid,
|
||||
"mint_url": config.mint_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Scan for visible WiFi networks from the router's radio.
|
||||
///
|
||||
/// Params: same host/credentials as other openwrt methods.
|
||||
pub(super) async fn handle_openwrt_scan_wifi(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "networks": result }))
|
||||
}
|
||||
|
||||
/// Configure WAN/WISP — connect the router to an upstream WiFi network.
|
||||
///
|
||||
/// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }`
|
||||
pub(super) async fn handle_openwrt_configure_wan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let ssid = p
|
||||
.get("ssid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?
|
||||
.to_string();
|
||||
let password = p
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let encryption = p
|
||||
.get("encryption")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("psk2")
|
||||
.to_string();
|
||||
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
|
||||
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
encryption,
|
||||
dhcp_start,
|
||||
dhcp_limit,
|
||||
masq,
|
||||
};
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||
fn parse_release(raw: &str) -> serde_json::Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for line in raw.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
m.insert(
|
||||
k.to_lowercase(),
|
||||
serde_json::Value::String(v.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
}
|
||||
|
||||
/// Extract AP wifi-iface sections from `uci show wireless` output.
|
||||
fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if let Some((lhs, rhs)) = line.trim().split_once('=') {
|
||||
let parts: Vec<&str> = lhs.splitn(3, '.').collect();
|
||||
if parts.len() == 3 && parts[0] == "wireless" {
|
||||
sections
|
||||
.entry(parts[1].to_string())
|
||||
.or_default()
|
||||
.insert(parts[2].to_string(), rhs.trim_matches('\'').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ifaces: Vec<serde_json::Value> = sections
|
||||
.into_iter()
|
||||
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
|
||||
.map(|(name, f)| {
|
||||
serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
|
||||
ifaces
|
||||
}
|
||||
|
||||
fn parse_ipv4(s: &str) -> Result<[u8; 4]> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
anyhow::bail!("Invalid IPv4: {}", s);
|
||||
}
|
||||
Ok([
|
||||
parts[0].parse()?,
|
||||
parts[1].parse()?,
|
||||
parts[2].parse()?,
|
||||
parts[3].parse()?,
|
||||
])
|
||||
}
|
||||
@@ -114,14 +114,66 @@ impl RpcHandler {
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Don't remove the entry — that's what made the card
|
||||
// handle_package_install saves the catalog-provided
|
||||
// dynamic app config to /var/lib/archipelago/app-configs
|
||||
// BEFORE the install pipeline runs, so a failure can
|
||||
// strand that file (and the optimistic state entry) with
|
||||
// no container behind it. Probe once here; both cleanup
|
||||
// branches below only fire when the app has no footprint.
|
||||
// A retry re-saves the config (the frontend sends
|
||||
// containerConfig on every install), so removal is safe.
|
||||
let left_container =
|
||||
failed_install_left_container(&handler, &package_id_spawn).await;
|
||||
// Dependency-gate rejections happen BEFORE any resource
|
||||
// (container/image/data dir) exists for this package, so
|
||||
// keeping the optimistic entry would leave a phantom
|
||||
// "Stopped" tile whose Start fails with `no such object`
|
||||
// (the log-confirmed LND fresh-install failure). Remove
|
||||
// the entry so the card reverts to installable, and
|
||||
// surface the reason as a notification instead.
|
||||
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
|
||||
{
|
||||
if !left_container {
|
||||
remove_dynamic_app_config(&package_id_spawn).await;
|
||||
}
|
||||
remove_entry_with_notification(
|
||||
&handler,
|
||||
&package_id_spawn,
|
||||
"install-deps",
|
||||
&gate.to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// A failed install that left NO container behind has no
|
||||
// real footprint either — keeping the entry would leave
|
||||
// the same phantom "Stopped" tile in My Apps (and the
|
||||
// scanner-side absence eviction takes 3 scans to catch
|
||||
// it). Remove the saved config + entry and surface the
|
||||
// failure as a notification, exactly like the gate case.
|
||||
if !left_container {
|
||||
remove_dynamic_app_config(&package_id_spawn).await;
|
||||
remove_entry_with_notification(
|
||||
&handler,
|
||||
&package_id_spawn,
|
||||
"install-failed",
|
||||
&format!("Install failed: {:#}", e),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// A container exists (crash-after-start kept for
|
||||
// visibility, retry over an existing install, upgrade) —
|
||||
// don't remove the entry, that's what made the card
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
// the entry visible with state=Stopped + the install
|
||||
// error in install_progress.message so the user can see
|
||||
// what went wrong and decide whether to retry or
|
||||
// uninstall. clear_install_progress would erase the
|
||||
// message, so we set it explicitly here instead.
|
||||
// message, so we set it explicitly here instead. The
|
||||
// phase is cleared (None) so no stale InstallPhase
|
||||
// lingers on the card.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
@@ -359,6 +411,77 @@ async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// True when the failed install still has a real footprint: any container
|
||||
/// belonging to `package_id` exists (any state — created/exited count too;
|
||||
/// the install-crash path deliberately keeps the exited container visible),
|
||||
/// or the app carries a user-stopped marker (Quadlet units run with `--rm`,
|
||||
/// so a cleanly user-stopped app legitimately has no podman record). Errors
|
||||
/// from the podman probe count as "exists" — never clean up on an uncertain
|
||||
/// reading.
|
||||
async fn failed_install_left_container(handler: &RpcHandler, package_id: &str) -> bool {
|
||||
if crate::crash_recovery::load_user_stopped(&handler.config.data_dir)
|
||||
.await
|
||||
.contains(package_id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match super::config::get_containers_for_app(package_id).await {
|
||||
Ok(containers) => !containers.is_empty(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"install cleanup {}: container probe failed ({:#}); keeping saved config",
|
||||
package_id, e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the catalog-provided dynamic app config that
|
||||
/// `handle_package_install` saved before the pipeline ran (mirror of the
|
||||
/// write in install.rs). Only called when the app has no container — for an
|
||||
/// existing install (retry/upgrade) the file is still the app's live runtime
|
||||
/// config and must be kept.
|
||||
async fn remove_dynamic_app_config(package_id: &str) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", package_id);
|
||||
match tokio::fs::remove_file(&config_path).await {
|
||||
Ok(()) => info!(
|
||||
"Removed dynamic app config for {} after failed install (no container)",
|
||||
package_id
|
||||
),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => warn!(
|
||||
"Failed to remove dynamic app config for {}: {}",
|
||||
package_id, e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the package's optimistic state entry (clearing any pending install
|
||||
/// phase with it) so the card reverts to installable, and surface the failure
|
||||
/// reason as an error notification instead.
|
||||
async fn remove_entry_with_notification(
|
||||
handler: &RpcHandler,
|
||||
package_id: &str,
|
||||
id_prefix: &str,
|
||||
message: &str,
|
||||
) {
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
/// no entry existed). Used for revert-on-failure.
|
||||
async fn flip_package_state(
|
||||
|
||||
@@ -94,35 +94,11 @@ async fn dynamic_app_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||
#[allow(dead_code)]
|
||||
pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
"docker.io/",
|
||||
"ghcr.io/",
|
||||
"localhost/",
|
||||
"git.tx1138.com/",
|
||||
"146.59.87.168:3000/",
|
||||
];
|
||||
|
||||
/// Validate Docker image against trusted registry allowlist.
|
||||
/// Validate a Docker image reference. Delegates to the shared policy in
|
||||
/// `container::image_policy` — the same rules the orchestrator enforces at
|
||||
/// its pull sites, so the two layers can't drift apart.
|
||||
pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||
if image.is_empty() || image.len() > 256 {
|
||||
return false;
|
||||
}
|
||||
// Reject shell metacharacters
|
||||
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
||||
if image.chars().any(|c| dangerous_chars.contains(&c)) {
|
||||
return false;
|
||||
}
|
||||
// Must come from a trusted registry — match the exact domain, not just prefix
|
||||
let registry = match image.split('/').next() {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
};
|
||||
matches!(
|
||||
registry,
|
||||
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000"
|
||||
)
|
||||
crate::container::image_policy::is_valid_docker_image(image)
|
||||
}
|
||||
|
||||
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||
@@ -684,10 +660,15 @@ pub(super) async fn get_app_config(
|
||||
),
|
||||
"bitcoin-core" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||
// never the LAN. In-node consumers dial the container's
|
||||
// archy-net alias directly; never bind the archy-net gateway
|
||||
// (10.89.0.1) — rootlessport can't hold it and podman run
|
||||
// fails outright (2026-07-09, .228). P2P 8333 stays public.
|
||||
"127.0.0.1:8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
"127.0.0.1:28332:28332".to_string(),
|
||||
"127.0.0.1:28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
@@ -707,12 +688,17 @@ pub(super) async fn get_app_config(
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
// -maxconnections=125 — default but explicit, so ops can
|
||||
// tune downward on bandwidth-constrained nodes.
|
||||
// Log volume: -printtoconsole=0 — bitcoind already writes
|
||||
// debug.log in the datadir (self-shrunk on restart); echoing it
|
||||
// to stdout too pushed every IBD "UpdateTip" line through
|
||||
// conmon into journald (>1 GB/day on a fresh node). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
Some(vec![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-printtoconsole=0".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||
"-par=0".to_string(),
|
||||
@@ -721,10 +707,15 @@ pub(super) async fn get_app_config(
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||
// never the LAN. In-node consumers dial the container's
|
||||
// archy-net alias directly; never bind the archy-net gateway
|
||||
// (10.89.0.1) — rootlessport can't hold it and podman run
|
||||
// fails outright (2026-07-09, .228). P2P 8333 stays public.
|
||||
"127.0.0.1:8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
"127.0.0.1:28332:28332".to_string(),
|
||||
"127.0.0.1:28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use super::runtime::manifest_apps_dirs;
|
||||
use crate::data_model::{PackageDataEntry, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::{AppManifest, Dependency};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
@@ -11,7 +13,38 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
|
||||
/// The manifest string dependency that declares "needs an archival
|
||||
/// (unpruned + txindex) Bitcoin node" — see `manifest_declares_archival_bitcoin`.
|
||||
const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
|
||||
|
||||
/// Whether `package_id`'s own on-disk manifest declares
|
||||
/// `dependencies: [bitcoin:archival]`. Manifest-driven alternative to the
|
||||
/// hardcoded id list below — a new app just declares the dependency instead
|
||||
/// of needing a code change here.
|
||||
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
|
||||
for apps_dir in manifest_apps_dirs() {
|
||||
let path = apps_dir.join(package_id).join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(manifest) = AppManifest::parse(&contents) else {
|
||||
continue;
|
||||
};
|
||||
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn dependency_list_declares_archival_bitcoin(deps: &[Dependency]) -> bool {
|
||||
deps.iter()
|
||||
.any(|dep| matches!(dep, Dependency::Simple(s) if s == ARCHIVAL_BITCOIN_DEPENDENCY))
|
||||
}
|
||||
|
||||
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
if manifest_declares_archival_bitcoin(package_id) {
|
||||
return true;
|
||||
}
|
||||
// Fallback for apps not yet migrated to the manifest declaration above.
|
||||
matches!(
|
||||
package_id,
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
|
||||
@@ -25,6 +58,7 @@ fn archival_bitcoin_required_message(package_id: &str) -> String {
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
pub has_electrumx: bool,
|
||||
@@ -194,6 +228,192 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounded dependency wait (install race fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Confirmed race on fresh nodes: the user clicks "Install LND" while
|
||||
// bitcoin-knots is itself still installing/starting. `check_install_deps`
|
||||
// rejected instantly ("LND requires a running Bitcoin node…") even though
|
||||
// Bitcoin came up 55s later. The fix: when the dependency is INSTALLED
|
||||
// (container exists in `podman ps -a`, or the package state knows about it)
|
||||
// but not Running yet, poll for up to DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL
|
||||
// (~3 minutes) before failing, surfacing "Waiting for X to start…" via the
|
||||
// install-progress message. If the dependency is not installed at all, fail
|
||||
// fast with the canonical `check_install_deps` message — waiting can't help.
|
||||
|
||||
/// Poll interval while waiting for an installed dependency to start.
|
||||
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// 36 × 5s = 3 minutes of bounded waiting.
|
||||
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
|
||||
|
||||
/// Marker error: the install was rejected by the dependency gate BEFORE any
|
||||
/// resource (container, image, data dir) was created for the package. The
|
||||
/// async install wrapper (`async_lifecycle.rs`) downcasts to this to remove
|
||||
/// the optimistic `Installing` state entry instead of leaving a phantom
|
||||
/// "Stopped" tile whose Start fails with `no such object`.
|
||||
#[derive(Debug)]
|
||||
pub(in crate::api::rpc) struct DependencyGateError(pub String);
|
||||
|
||||
impl std::fmt::Display for DependencyGateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DependencyGateError {}
|
||||
|
||||
/// One unsatisfied install dependency: a user-facing label plus the container
|
||||
/// name variants that would satisfy it.
|
||||
struct MissingDep {
|
||||
label: &'static str,
|
||||
containers: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Which dependencies `check_install_deps` would reject `package_id` over.
|
||||
/// Must stay in lockstep with the match arms in `check_install_deps` (the
|
||||
/// wait loop re-runs `check_install_deps` for the canonical error message).
|
||||
fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep> {
|
||||
const BITCOIN: MissingDep = MissingDep {
|
||||
label: "Bitcoin",
|
||||
containers: BITCOIN_NAMES,
|
||||
};
|
||||
const ELECTRUM: MissingDep = MissingDep {
|
||||
label: "ElectrumX",
|
||||
containers: ELECTRUM_NAMES,
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
match package_id {
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "lnd" | "btcpay-server" | "btcpayserver" => {
|
||||
if !deps.has_bitcoin {
|
||||
missing.push(BITCOIN);
|
||||
}
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
if !deps.has_bitcoin {
|
||||
missing.push(BITCOIN);
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push(ELECTRUM);
|
||||
}
|
||||
}
|
||||
// fedimint deliberately absent: check_install_deps allows it without
|
||||
// a local Bitcoin node (remote RPC configured in guardian setup).
|
||||
_ => {}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
fn join_dep_labels(missing: &[MissingDep]) -> String {
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
}
|
||||
|
||||
/// One snapshot of the dependency world, fed to [`wait_for_install_deps`].
|
||||
pub(super) struct DepProbe {
|
||||
/// Which dependency services are currently Running.
|
||||
pub running: RunningDeps,
|
||||
/// Container/package names that EXIST in any state — installed, but
|
||||
/// possibly not running yet (`podman ps -a` ∪ package-state entries).
|
||||
pub existing: Vec<String>,
|
||||
}
|
||||
|
||||
/// All container names known to podman in any state (`podman ps -a`).
|
||||
/// Conservative on probe failure: returns an empty list, which makes the
|
||||
/// wait loop fall back to the pre-fix fail-fast behavior.
|
||||
pub(super) async fn detect_existing_containers() -> Vec<String> {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(o)) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded dependency gate. Returns the (satisfied) `RunningDeps` snapshot,
|
||||
/// or a [`DependencyGateError`]:
|
||||
/// - immediately, when a missing dependency is not installed at all
|
||||
/// (canonical `check_install_deps` message), or
|
||||
/// - after `max_attempts × interval`, when an installed dependency never
|
||||
/// reached Running.
|
||||
///
|
||||
/// `probe` and `on_waiting` are injected so unit tests can drive the loop
|
||||
/// without a podman runtime; production wires them to
|
||||
/// `RpcHandler::dep_probe_for_install` / `set_install_message`.
|
||||
pub(super) async fn wait_for_install_deps<P, PF, L, LF>(
|
||||
package_id: &str,
|
||||
mut probe: P,
|
||||
mut on_waiting: L,
|
||||
max_attempts: u32,
|
||||
interval: std::time::Duration,
|
||||
) -> Result<RunningDeps>
|
||||
where
|
||||
P: FnMut() -> PF,
|
||||
PF: std::future::Future<Output = Result<DepProbe>>,
|
||||
L: FnMut(String) -> LF,
|
||||
LF: std::future::Future<Output = ()>,
|
||||
{
|
||||
let mut waited_attempts = 0u32;
|
||||
loop {
|
||||
let DepProbe { running, existing } = probe().await?;
|
||||
let missing = missing_install_deps(package_id, &running);
|
||||
if missing.is_empty() {
|
||||
// Keep behavior in lockstep with the canonical gate (covers any
|
||||
// future arm added there but not mirrored in missing_install_deps).
|
||||
check_install_deps(package_id, &running)?;
|
||||
return Ok(running);
|
||||
}
|
||||
|
||||
// Fail fast if any missing dependency has no installed container
|
||||
// under any name variant — waiting cannot satisfy it.
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c))
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(()) => format!("{package_id} dependencies are not running"),
|
||||
};
|
||||
return Err(anyhow::Error::new(DependencyGateError(msg)));
|
||||
}
|
||||
|
||||
if waited_attempts >= max_attempts {
|
||||
let labels = join_dep_labels(&missing);
|
||||
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||
"{labels} is installed but did not reach the running state within \
|
||||
{} seconds. Start {labels}, then install {package_id} again.",
|
||||
u64::from(max_attempts) * interval.as_secs()
|
||||
))));
|
||||
}
|
||||
waited_attempts += 1;
|
||||
|
||||
let labels = join_dep_labels(&missing);
|
||||
if waited_attempts == 1 {
|
||||
info!(
|
||||
"Install {package_id}: dependency {labels} installed but not running yet — \
|
||||
waiting up to {}s for it to start",
|
||||
u64::from(max_attempts) * interval.as_secs()
|
||||
);
|
||||
}
|
||||
on_waiting(format!("Waiting for {labels} to start…")).await;
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||
@@ -254,7 +474,7 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
|
||||
if detect_disk_gb().await < ARCHIVAL_BITCOIN_DISK_GB {
|
||||
anyhow::bail!(archival_bitcoin_required_message(package_id));
|
||||
}
|
||||
|
||||
@@ -279,10 +499,11 @@ fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_disk_gb() -> u64 {
|
||||
let output = std::process::Command::new("df")
|
||||
async fn detect_disk_gb() -> u64 {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-BG", "/var/lib/archipelago"])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = output else {
|
||||
return u64::MAX;
|
||||
};
|
||||
@@ -392,12 +613,33 @@ pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec
|
||||
/// error via `?`, every later member (the api + frontend) is then skipped,
|
||||
/// leaving the stack down until the health monitor recovers it minutes later.
|
||||
/// That was the source of mempool gate flakes #73 (frontend) / #74 (api).
|
||||
/// Orchestrator APP ids for a multi-container stack, in dependency-start
|
||||
/// order. Unlike `startup_order` (a union of CONTAINER-name variants across
|
||||
/// install generations, sometimes including foreign dependencies), every
|
||||
/// entry here is a real manifest app id the orchestrator can `start()` to
|
||||
/// recreate the member from scratch.
|
||||
///
|
||||
/// Used when a stack has NO live containers: under quadlet, `package.stop`
|
||||
/// removes every member container, so a subsequent `package.start` must
|
||||
/// resurrect member-by-member. Falling back to just the package id left the
|
||||
/// other members absent AND user-stopped (.228 indeedhub, 2026-07-09 — the
|
||||
/// reconciler then correctly refused to revive them).
|
||||
pub(super) fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
// Canonical table moved to crate::app_ops (shared with the reconciler's
|
||||
// in-flight-op guard).
|
||||
crate::app_ops::stack_member_app_ids(package_id)
|
||||
}
|
||||
|
||||
fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<String> {
|
||||
if containers.is_empty() {
|
||||
// Nothing is live under any known name. Fall back to the package id so
|
||||
// a single-container app whose container matches its id still gets one
|
||||
// start attempt; multi-container stacks with no live members are
|
||||
// surfaced as "no containers" by the caller's emptiness check.
|
||||
// Nothing is live under any known name. For known stacks, resurrect
|
||||
// every member via its app id (see stack_member_app_ids). Otherwise
|
||||
// fall back to the package id so a single-container app whose
|
||||
// container matches its id still gets one start attempt.
|
||||
let members = stack_member_app_ids(package_id);
|
||||
if !members.is_empty() {
|
||||
return members.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
return vec![package_id.to_string()];
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
@@ -473,7 +715,11 @@ pub(super) fn configure_fedimint_lnd(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{order_present_containers, requires_unpruned_bitcoin, startup_order};
|
||||
use super::{
|
||||
dependency_list_declares_archival_bitcoin, manifest_declares_archival_bitcoin,
|
||||
order_present_containers, requires_unpruned_bitcoin, startup_order,
|
||||
};
|
||||
use archipelago_container::Dependency;
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_never_injects_phantom_stack_members() {
|
||||
@@ -509,10 +755,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_empty_falls_back_to_package_id() {
|
||||
fn order_present_containers_empty_resurrects_stack_members() {
|
||||
// Under quadlet, package.stop removes every member container; the
|
||||
// start fallback must name each member APP id so the orchestrator can
|
||||
// recreate the whole stack (.228 indeedhub, 2026-07-09) — not just
|
||||
// the umbrella/package id.
|
||||
assert_eq!(
|
||||
order_present_containers("mempool", vec![]),
|
||||
vec!["mempool".to_string()]
|
||||
vec!["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||
);
|
||||
assert_eq!(
|
||||
order_present_containers("indeedhub", vec![]),
|
||||
vec![
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
order_present_containers("immich", vec![]),
|
||||
vec!["immich-postgres", "immich-redis", "immich"]
|
||||
);
|
||||
// Single-container apps keep the package-id fallback.
|
||||
assert_eq!(
|
||||
order_present_containers("vaultwarden", vec![]),
|
||||
vec!["vaultwarden".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,4 +818,275 @@ mod tests {
|
||||
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_matcher_finds_the_archival_marker_among_other_deps() {
|
||||
let deps = vec![
|
||||
Dependency::App {
|
||||
app_id: "bitcoin-knots".to_string(),
|
||||
version: Some(">=26.0".to_string()),
|
||||
},
|
||||
Dependency::Storage {
|
||||
storage: "50Gi".to_string(),
|
||||
},
|
||||
Dependency::Simple("bitcoin:archival".to_string()),
|
||||
];
|
||||
assert!(dependency_list_declares_archival_bitcoin(&deps));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_matcher_false_when_marker_absent() {
|
||||
let deps = vec![Dependency::App {
|
||||
app_id: "bitcoin-knots".to_string(),
|
||||
version: Some(">=26.0".to_string()),
|
||||
}];
|
||||
assert!(!dependency_list_declares_archival_bitcoin(&deps));
|
||||
assert!(!dependency_list_declares_archival_bitcoin(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_declared_archival_bitcoin_covers_a_new_app_without_a_code_change() {
|
||||
// electrumx and mempool declare `dependencies: [..., bitcoin:archival]`
|
||||
// on disk (apps/electrumx/manifest.yml, apps/mempool/manifest.yml) —
|
||||
// this is the manifest-driven path working end-to-end, not the
|
||||
// hardcoded id list. A future app only needs this manifest line, no
|
||||
// edit to `requires_unpruned_bitcoin`.
|
||||
assert!(manifest_declares_archival_bitcoin("electrumx"));
|
||||
assert!(manifest_declares_archival_bitcoin("mempool"));
|
||||
// An app whose manifest exists but never declares the marker.
|
||||
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
|
||||
// An id with no manifest on disk at all.
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
mod dep_wait {
|
||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
fn deps(has_bitcoin: bool, has_electrumx: bool) -> RunningDeps {
|
||||
RunningDeps {
|
||||
has_bitcoin,
|
||||
has_electrumx,
|
||||
has_lnd: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
|
||||
DepProbe {
|
||||
running: deps(has_bitcoin, has_electrumx),
|
||||
existing: existing.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects "Waiting for X to start…" labels emitted during the wait.
|
||||
fn label_sink() -> (
|
||||
Arc<Mutex<Vec<String>>>,
|
||||
impl FnMut(String) -> std::future::Ready<()>,
|
||||
) {
|
||||
let labels = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let labels = Arc::clone(&labels);
|
||||
move |msg: String| {
|
||||
labels.lock().unwrap().push(msg);
|
||||
std::future::ready(())
|
||||
}
|
||||
};
|
||||
(labels, sink)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passes_immediately_when_dependency_is_running() {
|
||||
let (labels, sink) = label_sink();
|
||||
let result = wait_for_install_deps(
|
||||
"lnd",
|
||||
|| async { Ok(probe(true, false, &["bitcoin-knots"])) },
|
||||
sink,
|
||||
3,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert!(labels.lock().unwrap().is_empty(), "no waiting expected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_fast_when_dependency_not_installed_at_all() {
|
||||
let calls = AtomicU32::new(0);
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"lnd",
|
||||
|| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(probe(false, false, &["uptime-kuma"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
// Single probe — no polling when waiting cannot help.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(labels.lock().unwrap().is_empty());
|
||||
// Canonical check_install_deps message, wrapped in the gate marker
|
||||
// so async_lifecycle removes the optimistic Installing entry.
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("LND requires a running Bitcoin node"),
|
||||
"unexpected message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waits_while_installed_dependency_starts_then_passes() {
|
||||
// Bitcoin container exists (installing/starting) but only reports
|
||||
// Running from the 3rd probe onward — the log-confirmed LND race.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
let result = wait_for_install_deps(
|
||||
"lnd",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async move { Ok(probe(n >= 2, false, &["bitcoin-knots"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(labels.len(), 2, "one waiting label per polling attempt");
|
||||
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn times_out_when_installed_dependency_never_runs() {
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"lnd",
|
||||
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||
sink,
|
||||
4,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("did not reach the running state within 0 seconds"),
|
||||
"unexpected message: {err}"
|
||||
);
|
||||
assert_eq!(labels.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
let result = wait_for_install_deps(
|
||||
"mempool",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
// Bitcoin comes up on probe 2, electrumx on probe 3.
|
||||
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
&[
|
||||
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
|
||||
"Waiting for ElectrumX to start…".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
|
||||
// Bitcoin is installed (waiting could help) but ElectrumX is not
|
||||
// installed at all — waiting can never satisfy the gate, so fail
|
||||
// fast with the canonical message.
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"mempool",
|
||||
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
assert!(labels.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
err.to_string().contains("Mempool requires"),
|
||||
"unexpected message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn variant_container_names_count_as_installed() {
|
||||
// bitcoin-core (not just bitcoin-knots) satisfies the "installed"
|
||||
// check for the wait path.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (_labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
let result = wait_for_install_deps(
|
||||
"electrumx",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async move { Ok(probe(n >= 1, false, &["bitcoin-core"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_without_dependency_gate_pass_untouched() {
|
||||
let (labels, sink) = label_sink();
|
||||
let result = wait_for_install_deps(
|
||||
"uptime-kuma",
|
||||
|| async { Ok(probe(false, false, &[])) },
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert!(labels.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
|
||||
// `mempool-api` is a legitimate direct `package.install` target
|
||||
// (`uses_orchestrator_install_flow` in install.rs), reachable without
|
||||
// going through the `mempool`/`mempool-web` umbrella id that the old
|
||||
// hardcoded fallback list only recognized. It was missing from that
|
||||
// list, so installing/repairing it directly skipped the archival
|
||||
// Bitcoin gate entirely. Its manifest now declares `bitcoin:archival`
|
||||
// directly, closing the gap the manifest-driven path exists for.
|
||||
assert!(requires_unpruned_bitcoin("mempool-api"));
|
||||
assert!(manifest_declares_archival_bitcoin("mempool-api"));
|
||||
// `archy-mempool-web` has no direct Bitcoin RPC access
|
||||
// (bitcoin_integration.rpc_access: none) and correctly stays excluded.
|
||||
assert!(!requires_unpruned_bitcoin("archy-mempool-web"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_bitcoin_pruning_compatibility, check_install_deps, configure_fedimint_lnd,
|
||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, RunningDeps,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -23,6 +24,12 @@ const IMAGE_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Append a timestamped line to the persistent install log.
|
||||
pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
// Always mirror to tracing/journald: the file append below has been
|
||||
// silently failing under the service sandbox (ProtectSystem=strict
|
||||
// leaves /var/log/archipelago read-only — container-installs.log has
|
||||
// been 0 bytes since April 2026), and these lifecycle breadcrumbs are
|
||||
// the primary forensic trail for gate failures.
|
||||
info!(target: "install_log", "{msg}");
|
||||
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
let line = format!("[{}] {}\n", ts, msg);
|
||||
if let Ok(mut f) = tokio::fs::OpenOptions::new()
|
||||
@@ -31,6 +38,8 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
.open(INSTALL_LOG)
|
||||
.await
|
||||
{
|
||||
// Fire-and-forget by design: install-log lines are advisory, and a
|
||||
// per-line warn would spam the very log stream that's failing.
|
||||
let _ = f.write_all(line.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
@@ -243,6 +252,17 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-version support: honor an install-time version selection for the
|
||||
// orchestrator-managed Bitcoin apps. Selecting the catalog default (or
|
||||
// omitting `version`) leaves the app unpinned (tracks latest); selecting
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
|
||||
// stacks also flip state to Installing immediately. Without this, the
|
||||
// backend's package state for stack apps stayed empty until the first
|
||||
@@ -254,8 +274,7 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
if matches!(package_id, "mempool" | "mempool-web") {
|
||||
let deps = self.running_deps_for_install(package_id).await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
}
|
||||
|
||||
@@ -278,9 +297,11 @@ impl RpcHandler {
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
// only when the cache does not show the dependency.
|
||||
let deps = self.running_deps_for_install(package_id).await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
// only when the cache does not show the dependency. When the dependency
|
||||
// is installed but not Running yet (the "clicked Install LND 55s before
|
||||
// Bitcoin was up" race), wait up to ~3 minutes for it instead of
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
let repaired_bitcoin_conf =
|
||||
@@ -309,8 +330,20 @@ impl RpcHandler {
|
||||
// mode).
|
||||
// The adoption block is being phased out as apps move to the
|
||||
// orchestrator path. Non-orchestrator apps still hit it.
|
||||
let orchestrator_managed =
|
||||
should_try_orchestrator_install(package_id, self.orchestrator.is_some());
|
||||
let orchestrator_managed = match self.orchestrator.as_ref() {
|
||||
// Migration allowlist OR any app whose manifest the orchestrator
|
||||
// knows (disk or signed-catalog overlay). A manifest-driven app
|
||||
// must never fall through to the legacy flow — it ignores the
|
||||
// manifest entirely and creates a bare container with no ports or
|
||||
// volumes (strfry, 2026-07-09).
|
||||
Some(orch) => {
|
||||
should_try_orchestrator_install(package_id, true)
|
||||
|| orch
|
||||
.knows_app(orchestrator_install_app_id(package_id))
|
||||
.await
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Check if container already exists (legacy adoption — non-orchestrator
|
||||
// apps only).
|
||||
@@ -705,6 +738,24 @@ impl RpcHandler {
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
for port in &ports {
|
||||
// Drop publishes whose bind address the host can't hold (e.g. the
|
||||
// archy-net gateway 10.89.0.1 under rootless podman) — passing
|
||||
// them through makes `podman run` itself fail and takes the app
|
||||
// down. "ip:host:container" has 2 colons; plain "host:container"
|
||||
// has 1 (no IPv6 binds in the legacy string table).
|
||||
let bind = if port.matches(':').count() == 2 {
|
||||
port.split(':').next().unwrap_or("")
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if !archipelago_container::manifest::host_can_bind_publish_ip(bind) {
|
||||
warn!(
|
||||
app = %package_id,
|
||||
publish = %port,
|
||||
"dropping publish: bind address not assignable on this host"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
@@ -765,6 +816,15 @@ impl RpcHandler {
|
||||
};
|
||||
run_args.push(&effective_image);
|
||||
|
||||
// Bitcoin-dependent apps (LND, electrs, BTCPay…) exit immediately if
|
||||
// bitcoind's RPC isn't answering when they start; the 60s post-start
|
||||
// poll then reads that exit as INSTALL CRASH and the whole install
|
||||
// fails — the "LND took 5 attempts" failure mode on fresh installs.
|
||||
// Gate the container start on the RPC actually responding (IBD is
|
||||
// fine — getblockchaininfo answers during sync) with a generous wait,
|
||||
// and fail with an actionable message instead of a crash-looping app.
|
||||
wait_for_bitcoin_rpc_gate(package_id).await?;
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL RUN: {} — podman run {} (image: {})",
|
||||
package_id, container_name, effective_image
|
||||
@@ -934,6 +994,27 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded dependency gate for installs: passes immediately when deps are
|
||||
/// running, fails fast (with the phantom-tile marker) when a dependency
|
||||
/// isn't installed at all, and otherwise waits up to
|
||||
/// `DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL` for an installed-but-
|
||||
/// starting dependency, surfacing "Waiting for X to start…" on the card.
|
||||
pub(super) async fn gate_install_deps(&self, package_id: &str) -> Result<RunningDeps> {
|
||||
wait_for_install_deps(
|
||||
package_id,
|
||||
|| async {
|
||||
Ok(DepProbe {
|
||||
running: self.running_deps_for_install(package_id).await?,
|
||||
existing: detect_existing_containers().await,
|
||||
})
|
||||
},
|
||||
|msg| async move { self.set_install_message(package_id, &msg).await },
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
DEP_WAIT_INTERVAL,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Private helpers for install --
|
||||
|
||||
/// Pull the image from a registry or verify a local image exists.
|
||||
@@ -1108,6 +1189,7 @@ impl RpcHandler {
|
||||
// the fresh pull instead of showing stale bytes from a prior
|
||||
// partial attempt.
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
let started = std::time::Instant::now();
|
||||
match self
|
||||
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
|
||||
.await?
|
||||
@@ -1118,6 +1200,24 @@ impl RpcHandler {
|
||||
break;
|
||||
}
|
||||
false => {
|
||||
// A fast failure (DNS miss / TCP reset while a fresh
|
||||
// node's network is still warming up) is worth one quick
|
||||
// retry — observed on first-boot installs where the
|
||||
// second click succeeded. A slow failure already spent
|
||||
// the 300s budget; retrying it just doubles the wait.
|
||||
if started.elapsed() < std::time::Duration::from_secs(30) {
|
||||
tracing::info!("Fast pull failure on {}, retrying once in 5s", url);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
if self
|
||||
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
|
||||
.await?
|
||||
{
|
||||
tracing::info!("Pulled {} from {} (retry)", docker_image, url);
|
||||
pulled_url = Some(url.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
"Pull attempt {}/{} failed for {}, trying next mirror",
|
||||
i + 1,
|
||||
@@ -1284,17 +1384,48 @@ impl RpcHandler {
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
//
|
||||
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
|
||||
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
|
||||
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
|
||||
// the bridge get connection refused (fresh-install LND crash-loop +
|
||||
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
|
||||
// 127.0.0.1-only on the host, so exposure is unchanged.
|
||||
// Prune sized to the data volume. A full archive needs ~810 GB and
|
||||
// grows; silently writing an unpruned config onto a small disk fills
|
||||
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
|
||||
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
|
||||
// archive; smaller ones get prune = 25% of the volume, clamped to
|
||||
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
|
||||
let prune_line = match bitcoin_data_volume_gb().await {
|
||||
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
|
||||
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
|
||||
info!(
|
||||
volume_gb = total_gb,
|
||||
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
|
||||
);
|
||||
format!("prune={}\n", prune_mb)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
printtoconsole=0\n\
|
||||
{}",
|
||||
rpcauth_line, prune_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
.await
|
||||
@@ -2155,13 +2286,14 @@ async fn ensure_host_port_listener(
|
||||
container_name: &str,
|
||||
runtime_ports: &[String],
|
||||
) -> Result<()> {
|
||||
let Some(port) = runtime_ports
|
||||
let mut port = runtime_ports
|
||||
.first()
|
||||
.and_then(|p| p.split(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.or_else(|| published_host_port(container_name))
|
||||
.or_else(|| required_host_port(package_id))
|
||||
else {
|
||||
.and_then(|p| p.parse::<u16>().ok());
|
||||
if port.is_none() {
|
||||
port = published_host_port(container_name).await;
|
||||
}
|
||||
let Some(port) = port.or_else(|| required_host_port(package_id)) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -2207,10 +2339,11 @@ async fn ensure_host_port_listener(
|
||||
))
|
||||
}
|
||||
|
||||
fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = std::process::Command::new("podman")
|
||||
async fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["port", container_name])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
@@ -2377,6 +2510,105 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
))
|
||||
}
|
||||
|
||||
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
|
||||
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
|
||||
/// unknown as "don't prune" to preserve archival defaults on big iron.
|
||||
async fn bitcoin_data_volume_gb() -> Option<u64> {
|
||||
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-k", target])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().nth(1)?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024 / 1024)
|
||||
}
|
||||
|
||||
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
|
||||
/// Works during IBD (the call answers with progress while syncing). Goes via
|
||||
/// the host-published RPC port, which fails in exactly the same conditions
|
||||
/// as the container-network path (bitcoind down, still binding, bad rpcbind).
|
||||
async fn bitcoin_rpc_answering() -> bool {
|
||||
let (user, pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "install-gate",
|
||||
"method": "getblockchaininfo",
|
||||
"params": [],
|
||||
});
|
||||
match client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&user, Some(&pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold the install of a bitcoin-dependent app until bitcoind's RPC answers,
|
||||
/// up to 3 minutes. No-op for apps that don't need bitcoin at start.
|
||||
async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
|
||||
if !matches!(
|
||||
package_id,
|
||||
"lnd" | "electrumx" | "electrs" | "mempool-electrs" | "btcpay-server" | "btcpayserver"
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(180);
|
||||
let mut announced = false;
|
||||
while !bitcoin_rpc_answering().await {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
install_log(&format!(
|
||||
"INSTALL FAIL: {} — Bitcoin RPC not answering after 180s; refusing to start a container that would crash-loop",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
anyhow::bail!(
|
||||
"Bitcoin's RPC is not responding, and {} needs it to start. \
|
||||
Bitcoin may still be starting up — wait a minute and try again. \
|
||||
If this persists, check the Bitcoin app logs.",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
if !announced {
|
||||
install_log(&format!(
|
||||
"INSTALL WAIT: {} — waiting for Bitcoin RPC to become ready (up to 3 min)",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
announced = true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
if announced {
|
||||
install_log(&format!(
|
||||
"INSTALL WAIT OK: {} — Bitcoin RPC is answering, starting container",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
@@ -2404,6 +2636,7 @@ ensure_line() {
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
ensure_line rpcthreads=16
|
||||
@@ -2427,6 +2660,36 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist an install-time version selection for a multi-version app. Selecting
|
||||
/// the catalog default (or a version equal to it) un-pins so the app tracks
|
||||
/// latest; selecting any other version pins it. Best-effort: a write failure
|
||||
/// just means the app installs at the catalog default.
|
||||
async fn persist_install_version_selection(app_id: &str, version: &str) {
|
||||
use crate::container::version_config::{read, write, AppVersionConfig};
|
||||
let is_default = crate::container::app_catalog::catalog_default_version(app_id)
|
||||
.map(|d| d == version)
|
||||
.unwrap_or(false);
|
||||
let existing = read(app_id);
|
||||
let cfg = AppVersionConfig {
|
||||
pinned_version: if is_default {
|
||||
None
|
||||
} else {
|
||||
Some(version.to_string())
|
||||
},
|
||||
auto_update: existing.auto_update,
|
||||
};
|
||||
if let Err(e) = write(app_id, &cfg) {
|
||||
tracing::warn!(app_id, version, error = %e, "failed to persist install-time version selection");
|
||||
} else {
|
||||
tracing::info!(
|
||||
app_id,
|
||||
version,
|
||||
pinned = !is_default,
|
||||
"persisted install-time version selection"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod install;
|
||||
mod lifecycle;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
mod stacks;
|
||||
mod update;
|
||||
mod validation;
|
||||
|
||||
@@ -61,6 +61,31 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||
/// to start…") without disturbing the current phase/byte counters.
|
||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
|
||||
@@ -63,6 +63,10 @@ impl RpcHandler {
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else if let Some(members) =
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
|
||||
{
|
||||
members
|
||||
} else {
|
||||
ordered_containers_for_start(package_id).await?
|
||||
};
|
||||
@@ -91,7 +95,10 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
|
||||
} else {
|
||||
@@ -102,6 +109,13 @@ impl RpcHandler {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
cascade_restart_address_caching_dependents(
|
||||
orchestrator.as_ref(),
|
||||
&state_manager,
|
||||
&data_dir,
|
||||
&package_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("package.start {} failed: {:#}", package_id_owned, e);
|
||||
@@ -151,6 +165,23 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
}
|
||||
|
||||
// Orchestrator-managed stacks are stopped via member APP ids (reverse
|
||||
// start order), never live container names: orchestrator.stop(app_id)
|
||||
// stops the member's quadlet .service (systemd removes the --rm
|
||||
// container), while a container NAME falls through the unknown-app-id
|
||||
// fallback to a raw `podman stop` that races systemd over the unit
|
||||
// (immich, gate 2026-07-09).
|
||||
let to_stop_ids = if !single_orchestrator_app {
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id).map(
|
||||
|mut members| {
|
||||
members.reverse();
|
||||
members
|
||||
},
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Mark as user-stopped BEFORE the spawn so health monitor and
|
||||
// crash recovery don't auto-restart mid-flight. Ordering is
|
||||
// load-bearing — see runtime.rs:145-148 original note.
|
||||
@@ -158,9 +189,17 @@ impl RpcHandler {
|
||||
for name in &containers {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
// Stack members are marked under their app ids too, so the reconcile
|
||||
// guard holds for members whose container is currently absent (a live
|
||||
// container list can't name those).
|
||||
if let Some(ids) = &to_stop_ids {
|
||||
for id in ids {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, id).await;
|
||||
}
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let to_stop = containers.clone();
|
||||
let to_stop = to_stop_ids.unwrap_or_else(|| containers.clone());
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
@@ -172,7 +211,9 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
|
||||
} else {
|
||||
@@ -213,14 +254,21 @@ impl RpcHandler {
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
let containers = if single_orchestrator_app {
|
||||
let mut containers = if single_orchestrator_app {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else {
|
||||
get_containers_for_app(package_id).await?
|
||||
};
|
||||
if containers.is_empty() {
|
||||
tracing::warn!("package.restart {}: no containers found", package_id);
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
// A stack whose containers were all removed (quadlet stop) can
|
||||
// still be restarted: resurrect it member-by-member, same as
|
||||
// package.start's no-live-containers fallback.
|
||||
let members = super::dependencies::stack_member_app_ids(package_id);
|
||||
if members.is_empty() {
|
||||
tracing::warn!("package.restart {}: no containers found", package_id);
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
}
|
||||
containers = members.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
|
||||
// Restart does not mark user-stopped; user wants the app to keep
|
||||
@@ -235,6 +283,14 @@ impl RpcHandler {
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let to_restart = if single_orchestrator_app {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else if let Some(members) =
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
|
||||
{
|
||||
// Restart stacks via member APP ids: restarting by live container
|
||||
// name podman-stops the quadlet container (systemd --rm removes
|
||||
// it) and the start half then finds no such container — a 5-min
|
||||
// outage + RESTART FAIL on immich (gate 2026-07-09).
|
||||
members
|
||||
} else {
|
||||
ordered_containers_for_start(package_id).await?
|
||||
};
|
||||
@@ -249,7 +305,10 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
|
||||
} else {
|
||||
@@ -260,6 +319,13 @@ impl RpcHandler {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
cascade_restart_address_caching_dependents(
|
||||
orchestrator.as_ref(),
|
||||
&state_manager,
|
||||
&data_dir,
|
||||
&package_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("package.restart {} failed: {:#}", package_id_owned, e);
|
||||
@@ -312,7 +378,16 @@ impl RpcHandler {
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
// Two distinct failure classes, kept separate so they don't get
|
||||
// conflated (the old single `errors` vec did, which caused the "ghost in
|
||||
// My Apps" bug): `container_errors` means a container could NOT be
|
||||
// removed (force-rm failed too) — the app is genuinely still present, so
|
||||
// we keep its state entry and surface a hard error. `cleanup_errors`
|
||||
// means volume/network/data-dir teardown left residue — the containers
|
||||
// are already gone, so the app IS uninstalled and MUST disappear from My
|
||||
// Apps; the residue is logged but never ghosts the app.
|
||||
let mut container_errors: Vec<String> = Vec::new();
|
||||
let mut cleanup_errors: Vec<String> = Vec::new();
|
||||
|
||||
self.set_uninstall_stage(
|
||||
package_id,
|
||||
@@ -370,7 +445,7 @@ impl RpcHandler {
|
||||
let msg =
|
||||
format!("Failed to remove {}: {}; {}", name, stderr.trim(), e);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
container_errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,12 +454,35 @@ impl RpcHandler {
|
||||
Err(force_err) => {
|
||||
let msg = format!("Failed to remove {}: {}; {}", name, e, force_err);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
container_errors.push(msg);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A container that survived even force-remove means the app is NOT
|
||||
// actually uninstalled — keep its state entry and fail so the spawned
|
||||
// task reverts it to its prior state (and the user can retry), rather
|
||||
// than orphaning a live container that's missing from My Apps.
|
||||
if !container_errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Uninstall {}: containers could not be removed: {:?}",
|
||||
package_id,
|
||||
container_errors
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Uninstall {} failed: {}",
|
||||
package_id,
|
||||
container_errors.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
// Containers are gone → the app is uninstalled. Remove its state entry
|
||||
// NOW, before the (possibly slow, possibly fallible) volume/data
|
||||
// teardown below, so My Apps updates immediately and a residue failure
|
||||
// can never leave a ghost. Reinstall/scan no longer see a stale entry.
|
||||
self.remove_package_state_entry(package_id).await;
|
||||
|
||||
self.set_uninstall_stage(package_id, "Cleaning up volumes")
|
||||
.await;
|
||||
// Avoid global Podman volume prune on production nodes: store-wide
|
||||
@@ -432,70 +530,73 @@ impl RpcHandler {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
let msg = format!("Failed to remove data {}: {}", dir, stderr.trim());
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
cleanup_errors.push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to remove data {}: {}", dir, e);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
cleanup_errors.push(msg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
// The app is already gone from My Apps (entry removed above). Residual
|
||||
// volume/data cleanup failures are logged but NEVER ghost the app — a
|
||||
// reinstall and the next uninstall both tolerate leftover dirs.
|
||||
if !cleanup_errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Uninstall {} completed with errors: {:?}",
|
||||
"Uninstall {} removed but left cleanup residue: {:?}",
|
||||
package_id,
|
||||
errors
|
||||
cleanup_errors
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Uninstall {} partially failed: {}",
|
||||
package_id,
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Uninstall {} complete: stopped={}, removed={}",
|
||||
"Uninstall {} complete: stopped={}, removed={}, cleanup_errors={}",
|
||||
package_id,
|
||||
stopped,
|
||||
removed
|
||||
removed,
|
||||
cleanup_errors.len()
|
||||
);
|
||||
|
||||
// Immediately remove from in-memory state so the UI updates without
|
||||
// waiting for the scanner's absence threshold (3 scans × 60s each).
|
||||
{
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let before = data.package_data.len();
|
||||
data.package_data.remove(package_id);
|
||||
// Also remove any alias keys (e.g. "bitcoin-knots" vs "bitcoin")
|
||||
let aliases: Vec<String> = data
|
||||
.package_data
|
||||
.keys()
|
||||
.filter(|k| {
|
||||
super::config::all_container_names(package_id)
|
||||
.iter()
|
||||
.any(|c| c.strip_prefix("archy-").unwrap_or(c) == k.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for alias in &aliases {
|
||||
data.package_data.remove(alias);
|
||||
}
|
||||
if data.package_data.len() < before {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "uninstalled",
|
||||
"stopped": stopped,
|
||||
"removed": removed,
|
||||
"cleanup_warnings": cleanup_errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a package's entry (and any alias keys) from persisted state so it
|
||||
/// disappears from My Apps immediately, without waiting for the scanner's
|
||||
/// absence threshold (3 scans × 60s). Called as soon as an uninstall has
|
||||
/// removed the app's containers — before the slower volume/data teardown —
|
||||
/// so a residue failure can never leave a ghost entry behind.
|
||||
async fn remove_package_state_entry(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let before = data.package_data.len();
|
||||
data.package_data.remove(package_id);
|
||||
// Also remove any alias keys (e.g. "bitcoin-knots" vs "bitcoin").
|
||||
let aliases: Vec<String> = data
|
||||
.package_data
|
||||
.keys()
|
||||
.filter(|k| {
|
||||
super::config::all_container_names(package_id)
|
||||
.iter()
|
||||
.any(|c| c.strip_prefix("archy-").unwrap_or(c) == k.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for alias in &aliases {
|
||||
data.package_data.remove(alias);
|
||||
}
|
||||
if data.package_data.len() < before {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
||||
&self,
|
||||
@@ -730,7 +831,13 @@ async fn do_orchestrator_package_start(
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
do_package_start(&[name.clone()]).await?;
|
||||
// Collect instead of `?`: aborting here skipped every later
|
||||
// stack member (mempool gate flakes #73/#74 — see
|
||||
// order_present_containers).
|
||||
if let Err(e) = do_package_start(&[name.clone()]).await {
|
||||
tracing::error!(container = %name, error = %e, "fallback container start failed");
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator start failed");
|
||||
@@ -891,6 +998,8 @@ async fn inspect_runtime_container_state(container_name: &str) -> Result<Option<
|
||||
fn is_missing_container_error(stderr: &str) -> bool {
|
||||
stderr.contains("no such container")
|
||||
|| stderr.contains("no container with name")
|
||||
// podman 5.x `inspect` phrasing: `Error: no such object: "name"`
|
||||
|| stderr.contains("no such object")
|
||||
|| stderr.contains("does not exist")
|
||||
|| stderr.contains("not found")
|
||||
}
|
||||
@@ -980,6 +1089,13 @@ async fn do_orchestrator_package_stop(
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
// A member whose container is already gone IS stopped: quadlet
|
||||
// stop removes the --rm container before the orchestrator's
|
||||
// defensive `podman stop` fallback probes it, and a stack member
|
||||
// can be absent outright (stopped earlier, or never revived).
|
||||
Err(e) if is_missing_container_error(&format!("{:#}", e)) => {
|
||||
tracing::debug!(container = %name, error = %e, "stop: container already absent — treating as stopped");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
@@ -1001,6 +1117,103 @@ fn orchestrator_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Member APP ids (start order) for an orchestrator-managed stack, or None
|
||||
/// when there's no orchestrator / the id isn't a known stack. Lifecycle ops
|
||||
/// on stacks must address members by app id so the orchestrator drives the
|
||||
/// quadlet .service; addressing live container names falls through the
|
||||
/// unknown-app-id fallback to raw podman stop/start, which races systemd's
|
||||
/// --rm cleanup (the container is removed on stop, the start half then finds
|
||||
/// nothing — immich restart, gate 2026-07-09).
|
||||
fn orchestrator_stack_members(has_orchestrator: bool, package_id: &str) -> Option<Vec<String>> {
|
||||
if !has_orchestrator {
|
||||
return None;
|
||||
}
|
||||
let members = super::dependencies::stack_member_app_ids(package_id);
|
||||
if members.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(members.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// See crate::app_ops::address_caching_dependents — shared with the
|
||||
/// reconciler, which cascades after its own recreates.
|
||||
fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
crate::app_ops::address_caching_dependents(package_id)
|
||||
}
|
||||
|
||||
/// After a backend start/restart lands, bounce its address-caching dependents
|
||||
/// (see above). Runs inside the backend worker's op-lock scope and takes each
|
||||
/// dependent's own op lock — lock order is always backend → dependent, and no
|
||||
/// dependent op takes a backend lock, so the nesting cannot deadlock.
|
||||
async fn cascade_restart_address_caching_dependents(
|
||||
orchestrator: Option<&Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||
state_manager: &crate::state::StateManager,
|
||||
data_dir: &std::path::Path,
|
||||
backend_id: &str,
|
||||
) {
|
||||
for dep in address_caching_dependents(backend_id) {
|
||||
// A user-stopped or not-running dependent holds no live connection —
|
||||
// starting it here would override an explicit operator decision.
|
||||
if crate::crash_recovery::load_user_stopped(data_dir)
|
||||
.await
|
||||
.contains(*dep)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let running = match podman_control(&["ps", "--format", "{{.Names}}"]).await {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.any(|l| l.trim() == *dep),
|
||||
_ => false,
|
||||
};
|
||||
if !running {
|
||||
continue;
|
||||
}
|
||||
install_log(&format!(
|
||||
"CASCADE RESTART: {dep} (backend {backend_id} restarted; {dep} caches its resolved address)"
|
||||
))
|
||||
.await;
|
||||
let op_lock = app_op_lock(dep);
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let prev = flip_package_state(state_manager, dep, PackageState::Restarting).await;
|
||||
let target = vec![dep.to_string()];
|
||||
let result = if let Some(orch) = orchestrator {
|
||||
do_orchestrator_package_restart(orch.as_ref(), &target).await
|
||||
} else {
|
||||
do_package_restart(&target).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reconcile_companions_for(dep).await;
|
||||
set_package_state(state_manager, dep, PackageState::Running).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(dependent = %dep, backend = %backend_id, error = %e, "cascade restart failed");
|
||||
install_log(&format!("CASCADE RESTART FAIL: {dep} — {e:#}")).await;
|
||||
if let Some(prev) = prev {
|
||||
set_package_state(state_manager, dep, prev).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-app lifecycle-operation locks. package.start/stop/restart reply
|
||||
/// immediately and run their multi-container sequences in spawned tasks;
|
||||
/// unserialized, back-to-back RPCs interleave those sequences (gate run D,
|
||||
/// 2026-07-09: package.start's member bring-up raced the still-running
|
||||
/// package.stop's member shutdown — archy-mempool-db was stopped 2 seconds
|
||||
/// after it started and the mempool stack finished with zero containers).
|
||||
/// Workers take the app's lock as their first await; tokio's Mutex is fair
|
||||
/// (FIFO), so queued operations run in RPC arrival order and the final
|
||||
/// state matches the last request.
|
||||
/// Registry lives in crate::app_ops so background actors (reconciler) can
|
||||
/// probe in-flight ops without an api ↔ container dependency cycle.
|
||||
fn app_op_lock(package_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
crate::app_ops::op_lock(orchestrator_app_id(package_id))
|
||||
}
|
||||
|
||||
fn uses_single_orchestrator_app(package_id: &str) -> bool {
|
||||
startup_order(package_id).is_empty()
|
||||
&& matches!(
|
||||
@@ -1568,7 +1781,7 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
@@ -1912,6 +2125,29 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
"archy-btcpay-db".into(),
|
||||
],
|
||||
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
|
||||
// IndeedHub: 7-container stack; same rationale as immich below —
|
||||
// without this, uninstalling "indeedhub" leaves the six companions
|
||||
// enabled for the reconciler to resurrect forever.
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
// Immich: multi-container stack, mirrors `immich_stack_app_ids` in
|
||||
// stacks.rs. Without this, uninstalling "immich" only disabled the
|
||||
// orchestrator-tracked "immich" app_id — "immich-postgres" and
|
||||
// "immich-redis" stayed enabled, so the boot reconciler kept
|
||||
// restarting their leftover stopped containers forever after the
|
||||
// generic uninstall path stopped them (`.198`, 2026-07-01).
|
||||
"immich" => vec![
|
||||
"immich-postgres".into(),
|
||||
"immich-redis".into(),
|
||||
"immich".into(),
|
||||
],
|
||||
_ => vec![package_id.to_string()],
|
||||
}
|
||||
}
|
||||
@@ -1920,6 +2156,24 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
||||
// container says `Error: no such object: "mempool"` — the classifier
|
||||
// missed it, so a plain missing container surfaced as a hard inspect
|
||||
// error and package.start aborted instead of proceeding to recreate.
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no such object: \"mempool\""
|
||||
));
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no such container mempool"
|
||||
));
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no container with name or id \"x\" found"
|
||||
));
|
||||
assert!(!is_missing_container_error("Error: OCI runtime error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
|
||||
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
|
||||
@@ -1931,4 +2185,38 @@ mod tests {
|
||||
fn runtime_host_ports_preserve_legacy_extra_ports() {
|
||||
assert_eq!(runtime_host_ports("gitea"), vec![3001, 2222, 3000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeedhub_uninstall_covers_every_sibling_orchestrator_app_id() {
|
||||
let ids = orchestrator_uninstall_app_ids("indeedhub");
|
||||
for expected in [
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
] {
|
||||
assert!(
|
||||
ids.iter().any(|id| id == expected),
|
||||
"missing {expected} in {ids:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immich_uninstall_covers_every_sibling_orchestrator_app_id() {
|
||||
// Regression: uninstalling "immich" used to only disable the
|
||||
// "immich" app_id itself, leaving immich-postgres/immich-redis
|
||||
// enabled — the boot reconciler kept restarting their leftover
|
||||
// stopped containers forever (.198, 2026-07-01).
|
||||
let ids = orchestrator_uninstall_app_ids("immich");
|
||||
for expected in ["immich-postgres", "immich-redis", "immich"] {
|
||||
assert!(
|
||||
ids.iter().any(|id| id == expected),
|
||||
"missing {expected} in {ids:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Multi-version support — version listing + in-app version switch / pin /
|
||||
//! auto-update toggle (`docs/bitcoin-multi-version-design.md` §3 Phase 3).
|
||||
//!
|
||||
//! Two RPCs:
|
||||
//! - `package.versions` — read the selectable versions for an app plus the
|
||||
//! runner's current pin / auto-update preference and (best-effort) the
|
||||
//! version actually running. Drives the install modal + "Version & Updates"
|
||||
//! card.
|
||||
//! - `package.set-config` — persist a version pin (or un-pin to track latest)
|
||||
//! and/or the auto-update toggle, then recreate the app at the chosen image
|
||||
//! when the version actually changed. A DOWNGRADE (older release over a
|
||||
//! newer chainstate — the highest-risk operation, design §4) is refused
|
||||
//! unless the caller passes `confirm: true`, so the UI can warn first.
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
use super::install::install_log;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::{app_catalog, version_config};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Apps that participate in multi-version selection today. Kept narrow on
|
||||
/// purpose: version switching recreates the container, which is only safe for
|
||||
/// the single-container, orchestrator-managed Bitcoin backends whose data and
|
||||
/// downgrade semantics we understand. Any app the catalog gives a `versions[]`
|
||||
/// list also qualifies (third-party registry apps inherit the capability).
|
||||
fn supports_versions(app_id: &str) -> bool {
|
||||
matches!(app_id, "bitcoin-core" | "bitcoin-knots")
|
||||
|| !app_catalog::catalog_versions(app_id).is_empty()
|
||||
}
|
||||
|
||||
/// Extract the tag from a full image reference, leaving a `registry:port/repo`
|
||||
/// host-port colon intact (only a colon AFTER the last `/` is a tag).
|
||||
fn image_tag(image: &str) -> Option<String> {
|
||||
let after_slash = image.rsplit_once('/').map(|(_, r)| r).unwrap_or(image);
|
||||
after_slash
|
||||
.rsplit_once(':')
|
||||
.map(|(_, tag)| tag.to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Best-effort: the version tag of the backend container actually running for
|
||||
/// `app_id`, by inspecting its image. `None` when not installed or unreadable.
|
||||
async fn installed_version(app_id: &str) -> Option<String> {
|
||||
let containers = get_containers_for_app(app_id).await.ok()?;
|
||||
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
|
||||
let name = containers
|
||||
.iter()
|
||||
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
|
||||
.or_else(|| containers.first())?;
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.ImageName}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let tag = image_tag(&image)?;
|
||||
// A floating tag (latest/stable/...) names the reference used to CREATE the
|
||||
// container, not what's actually running — podman never re-resolves it once
|
||||
// cached, so a stale local `:latest` reports "latest" even when the real
|
||||
// `latest` moved on months ago (.228, 2026-07-01: ran a 4-month-old cached
|
||||
// image while a newer one already sat locally, unused). Ask the Bitcoin
|
||||
// backends directly instead of trusting the tag literal in that case.
|
||||
if is_floating_tag(&tag) {
|
||||
if let Some(real) = bitcoind_reported_version(app_id, name).await {
|
||||
return Some(real);
|
||||
}
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
/// Best-effort: ask the running bitcoind binary for its own version, trimmed to
|
||||
/// the catalog's version-tag format (e.g. `29.3.knots20260210`, `29.2`). `None`
|
||||
/// for apps other than the Bitcoin backends (no generic way to introspect a
|
||||
/// third-party image's content version this way) or if the exec fails.
|
||||
async fn bitcoind_reported_version(app_id: &str, container_name: &str) -> Option<String> {
|
||||
if !matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
return None;
|
||||
}
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["exec", container_name, "bitcoind", "--version"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_bitcoind_version_output(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parses e.g. "Bitcoin Knots daemon version v29.3.knots20260210\n..." or
|
||||
/// "Bitcoin Core version v29.2.0\n..." down to the version tag after `version v`.
|
||||
fn parse_bitcoind_version_output(output: &str) -> Option<String> {
|
||||
let first_line = output.lines().next()?;
|
||||
let (_, version) = first_line.rsplit_once("version v")?;
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(version.to_string())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// `package.versions` — what a runner can install / switch to for this app,
|
||||
/// plus their current preference and the running version.
|
||||
pub(in crate::api::rpc) async fn handle_package_versions(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let versions = app_catalog::catalog_versions(app_id);
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
"installedVersion": installed,
|
||||
"pinnedVersion": cfg.pinned_version,
|
||||
"autoUpdate": cfg.auto_update,
|
||||
"versions": versions.iter().map(|v| serde_json::json!({
|
||||
"version": v.version,
|
||||
"default": v.default,
|
||||
"deprecated": v.deprecated,
|
||||
"eol": v.eol,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `package.set-config` — persist version pin + auto-update preference and
|
||||
/// recreate on an actual version change. Downgrades require `confirm:true`.
|
||||
pub(in crate::api::rpc) async fn handle_package_set_config(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
validate_app_id(&app_id)?;
|
||||
|
||||
if !supports_versions(&app_id) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} has no selectable versions in the catalog",
|
||||
app_id
|
||||
));
|
||||
}
|
||||
|
||||
let confirm = params
|
||||
.get("confirm")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let existing = version_config::read(&app_id);
|
||||
let default = app_catalog::catalog_default_version(&app_id);
|
||||
|
||||
// ---- Resolve the requested pin (if a version was supplied) ----------
|
||||
// Absent `version` => leave the pin unchanged (an auto-update-only edit).
|
||||
// `version == default` => un-pin (track latest). Any other version must
|
||||
// exist in the catalog and resolve to a same-repo image, else reject.
|
||||
let version_param = params
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let mut new_pin = existing.pinned_version.clone();
|
||||
let mut version_changed = false;
|
||||
if let Some(req) = version_param.as_deref() {
|
||||
let resolved_pin = if default.as_deref() == Some(req) {
|
||||
None // selecting the default un-pins
|
||||
} else {
|
||||
// Validate the version is real + same-repo before pinning.
|
||||
if !app_catalog::catalog_versions(&app_id)
|
||||
.iter()
|
||||
.any(|v| v.version == req)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"version {} is not offered for {}",
|
||||
req,
|
||||
app_id
|
||||
));
|
||||
}
|
||||
Some(req.to_string())
|
||||
};
|
||||
version_changed = resolved_pin != existing.pinned_version;
|
||||
new_pin = resolved_pin;
|
||||
}
|
||||
|
||||
let new_auto_update = params
|
||||
.get("autoUpdate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(existing.auto_update);
|
||||
|
||||
// ---- Downgrade gate (design §4: warn + confirm + allow) -------------
|
||||
// "Current" = what wrote the on-disk chainstate: the running version if
|
||||
// we can read it, else the existing pin, else the catalog default.
|
||||
if version_changed {
|
||||
let target = version_param.as_deref().unwrap_or_default();
|
||||
let current = installed_version(&app_id)
|
||||
.await
|
||||
.or_else(|| existing.pinned_version.clone())
|
||||
.or_else(|| default.clone());
|
||||
if let Some(current) = current {
|
||||
if version_config::is_downgrade(¤t, target) && !confirm {
|
||||
warn!(
|
||||
"set-config {}: refusing un-confirmed downgrade {} -> {}",
|
||||
app_id, current, target
|
||||
);
|
||||
return Ok(serde_json::json!({
|
||||
"status": "confirm_required",
|
||||
"kind": "downgrade",
|
||||
"id": app_id,
|
||||
"currentVersion": current,
|
||||
"targetVersion": target,
|
||||
"warning": format!(
|
||||
"Switching {app_id} from {current} down to {target} is a \
|
||||
downgrade. Bitcoin may refuse to start on a chainstate \
|
||||
written by the newer version without a full reindex, and \
|
||||
a pruned node can lose block data. Re-confirm to proceed."
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Persist preference --------------------------------------------
|
||||
version_config::write(
|
||||
&app_id,
|
||||
&version_config::AppVersionConfig {
|
||||
pinned_version: new_pin.clone(),
|
||||
auto_update: new_auto_update,
|
||||
},
|
||||
)?;
|
||||
install_log(&format!(
|
||||
"SET-CONFIG {}: pinned={:?} autoUpdate={} (version_changed={})",
|
||||
app_id, new_pin, new_auto_update, version_changed
|
||||
))
|
||||
.await;
|
||||
info!(
|
||||
app_id = %app_id,
|
||||
pinned = ?new_pin,
|
||||
auto_update = new_auto_update,
|
||||
version_changed,
|
||||
"package.set-config applied"
|
||||
);
|
||||
|
||||
// ---- Recreate when the version actually changed + app is installed --
|
||||
// The orchestrator's install/recreate path reads the pin we just wrote
|
||||
// (prod_orchestrator image resolution), so reusing the update machinery
|
||||
// pulls + recreates at the chosen image. An auto-update-only edit, or a
|
||||
// change to a not-installed app, just persists the preference.
|
||||
let mut recreating = false;
|
||||
if version_changed {
|
||||
let installed = get_containers_for_app(&app_id)
|
||||
.await
|
||||
.map(|c| !c.is_empty())
|
||||
.unwrap_or(false);
|
||||
if installed {
|
||||
recreating = true;
|
||||
// Fire the existing async update flow; it flips state to
|
||||
// Updating and recreates honoring the new pin. The UI polls.
|
||||
self.clone()
|
||||
.spawn_package_update(Some(serde_json::json!({ "id": app_id })))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"id": app_id,
|
||||
"pinnedVersion": new_pin,
|
||||
"autoUpdate": new_auto_update,
|
||||
"versionChanged": version_changed,
|
||||
"recreating": recreating,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{image_tag, is_floating_tag, parse_bitcoind_version_output};
|
||||
|
||||
#[test]
|
||||
fn floating_tag_detects_generic_channel_names() {
|
||||
for tag in ["latest", "stable", "release", "main"] {
|
||||
assert!(is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
for tag in ["29.3.knots20260508", "28.4", "v29.2.0"] {
|
||||
assert!(!is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_knots_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output(
|
||||
"Bitcoin Knots daemon version v29.3.knots20260210\nCopyright...\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("29.3.knots20260210")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_core_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output("Bitcoin Core version v29.2.0\n").as_deref(),
|
||||
Some("29.2.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_output_has_no_version_marker() {
|
||||
assert_eq!(parse_bitcoind_version_output("garbage output\n"), None);
|
||||
assert_eq!(parse_bitcoind_version_output(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_tag_keeps_registry_port_colon() {
|
||||
assert_eq!(
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin:28.4").as_deref(),
|
||||
Some("28.4")
|
||||
);
|
||||
assert_eq!(
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
|
||||
Some("29.3.knots20260508")
|
||||
);
|
||||
// No tag => None (don't mistake the registry port for a tag).
|
||||
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
|
||||
assert_eq!(
|
||||
image_tag("docker.io/library/redis:7"),
|
||||
Some("7".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
@@ -131,6 +130,8 @@ async fn wait_for_stack_container_absent(container_name: &str, timeout: Duration
|
||||
fn is_missing_container_error(stderr: &str) -> bool {
|
||||
stderr.contains("no such container")
|
||||
|| stderr.contains("no container with name")
|
||||
// podman 5.x `inspect` phrasing: `Error: no such object: "name"`
|
||||
|| stderr.contains("no such object")
|
||||
|| stderr.contains("does not exist")
|
||||
|| stderr.contains("not found")
|
||||
}
|
||||
@@ -696,6 +697,16 @@ fn immich_stack_app_ids() -> &'static [&'static str] {
|
||||
&["immich-postgres", "immich-redis", "immich"]
|
||||
}
|
||||
|
||||
fn netbird_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency/startup order: the combined management/signal/relay server
|
||||
// first (it owns the base64 relay/store secrets + the sqlite store, and is
|
||||
// the OIDC issuer the others point at), then the dashboard SPA, then the
|
||||
// user-facing TLS proxy ("netbird", which carries the self-signed cert +
|
||||
// the templated nginx.conf and is the launcher). Mirrors the netbird
|
||||
// startup_order in dependencies.rs.
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
}
|
||||
|
||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency order: backends + their generated secrets first, then the api
|
||||
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
||||
@@ -715,10 +726,6 @@ fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
|
||||
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0";
|
||||
const NETBIRD_SERVER_IMAGE: &str = "docker.io/netbirdio/netbird-server:0.71.2";
|
||||
const NETBIRD_PROXY_IMAGE: &str = "docker.io/library/nginx:1.27-alpine";
|
||||
|
||||
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
let exists = podman_stack_status(&["image", "exists", image], PODMAN_STACK_PROBE_TIMEOUT).await;
|
||||
@@ -1004,9 +1011,9 @@ impl RpcHandler {
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
// Dependency check: Bitcoin must be running
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("btcpay-server", &deps)?;
|
||||
// Dependency check: Bitcoin must be running. Bounded wait covers the
|
||||
// "installed but still starting" race instead of failing instantly.
|
||||
self.gate_install_deps("btcpay-server").await?;
|
||||
|
||||
install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await;
|
||||
|
||||
@@ -1828,6 +1835,27 @@ impl RpcHandler {
|
||||
|
||||
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
||||
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
||||
// Manifest-driven path (#20 phase 4): render the 3-member stack from
|
||||
// apps/netbird-*/manifest.yml via the orchestrator — dedicated
|
||||
// netbird-net + network_aliases, base64 generated_secrets, a self-signed
|
||||
// TLS cert (generated_certs) so the dashboard gets a secure context for
|
||||
// OIDC PKCE (#15), and templated config.yaml/nginx.conf rendered from
|
||||
// host facts + the netbird-net gateway. The manifests use the exact live
|
||||
// container names, so on an existing node this ADOPTS the running stack
|
||||
// rather than recreating it (the sqlite store + base64 keys are
|
||||
// preserved — ensure_generated_secrets no-ops on existing files).
|
||||
//
|
||||
// #20 ph4: the legacy hardcoded `podman run` installer was DELETED — the
|
||||
// signed catalog always ships apps/netbird-*/manifest.yml, so there is no
|
||||
// in-Rust fallback. If the orchestrator doesn't know these app_ids and no
|
||||
// running stack exists to adopt, install errors rather than silently
|
||||
// diverging from the manifest contract.
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "netbird", netbird_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"netbird",
|
||||
"netbird",
|
||||
@@ -1838,491 +1866,12 @@ impl RpcHandler {
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
install_log("INSTALL START: netbird stack (dashboard + server)").await;
|
||||
info!("Installing self-hosted NetBird stack");
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::PullingImage)
|
||||
.await;
|
||||
for (i, image) in [
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
NETBIRD_SERVER_IMAGE,
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.set_install_progress("netbird", i as u64, 3).await;
|
||||
pull_image_with_retry(image)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull NetBird image: {}", image))?;
|
||||
}
|
||||
self.set_install_progress("netbird", 3, 3).await;
|
||||
|
||||
for name in ["netbird", "netbird-dashboard", "netbird-server"] {
|
||||
let _ = podman_stack_status(&["rm", "-f", name], PODMAN_STACK_PROBE_TIMEOUT).await;
|
||||
}
|
||||
let _ = podman_stack_status(
|
||||
&["network", "rm", "-f", "netbird-net"],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
anyhow::bail!(
|
||||
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
|
||||
)
|
||||
.await;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
tokio::fs::create_dir_all("/var/lib/archipelago/netbird/data")
|
||||
.await
|
||||
.context("Failed to create NetBird data directory")?;
|
||||
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
|
||||
// Create the network FIRST so we can read back the gateway it was
|
||||
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
|
||||
// nginx needs as an explicit `resolver` to re-resolve container names
|
||||
// (issue #15: without it nginx caches a container IP and 502s forever
|
||||
// once that IP changes on restart/reboot).
|
||||
let _ = podman_stack_status(
|
||||
&["network", "create", "netbird-net"],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolver_ip = netbird_net_resolver_ip().await;
|
||||
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
|
||||
ensure_netbird_tls_cert(&host_ip).await?;
|
||||
|
||||
let mut server_cmd = tokio::process::Command::new("podman");
|
||||
server_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-server",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--network-alias",
|
||||
"netbird-server",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8086:80",
|
||||
"-p",
|
||||
"3478:3478/udp",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/data:/var/lib/netbird",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/config.yaml:/etc/netbird/config.yaml:ro",
|
||||
NETBIRD_SERVER_IMAGE,
|
||||
"--config",
|
||||
"/etc/netbird/config.yaml",
|
||||
]);
|
||||
run_required_stack_command("netbird", "create server", &mut server_cmd).await?;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::StartingContainer)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let mut dashboard_cmd = tokio::process::Command::new("podman");
|
||||
dashboard_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
// Explicit alias so the proxy can always resolve `netbird-dashboard`
|
||||
// via Podman DNS — don't rely on implicit container-name aliasing.
|
||||
"--network-alias",
|
||||
"netbird-dashboard",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?;
|
||||
|
||||
let mut proxy_cmd = tokio::process::Command::new("podman");
|
||||
proxy_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
// 8087 publishes the TLS listener — netbird's dashboard requires a
|
||||
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
|
||||
"-p",
|
||||
"8087:443",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
|
||||
wait_for_stack_containers(
|
||||
"netbird",
|
||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
// Containers being "running" is NOT the same as the embedded OIDC
|
||||
// provider being ready (#10). The dashboard SPA opens right after install
|
||||
// and, if it loads before /oauth2/.well-known is served, caches a bad
|
||||
// auth state — the user appears logged-in but can't log out until it
|
||||
// self-corrects. Wait (best-effort) for OIDC discovery to answer before
|
||||
// we report Done, so the first dashboard load sees a ready provider.
|
||||
wait_for_netbird_oidc_ready(Duration::from_secs(60)).await;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("netbird", InstallPhase::Done).await;
|
||||
self.clear_install_progress("netbird").await;
|
||||
|
||||
install_log("INSTALL OK: netbird stack").await;
|
||||
info!("NetBird stack installed");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "netbird",
|
||||
"message": "NetBird self-hosted stack installed",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort wait for NetBird's embedded OIDC provider to start serving its
|
||||
/// discovery document. The management server publishes 8086:80 on the host and
|
||||
/// is the issuer at `/oauth2`, so its `.well-known/openid-configuration` is the
|
||||
/// signal that the dashboard's login/logout flow will work. Polls until a 2xx
|
||||
/// or the timeout — NEVER fails the install (the stack is already running; this
|
||||
/// only narrows the post-install race window in #10).
|
||||
async fn wait_for_netbird_oidc_ready(timeout: Duration) {
|
||||
let url = "http://127.0.0.1:8086/oauth2/.well-known/openid-configuration";
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(resp) = client.get(url).send().await {
|
||||
if resp.status().is_success() {
|
||||
info!("NetBird OIDC discovery is ready");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
info!("NetBird OIDC discovery not ready within timeout — proceeding anyway");
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
let path = format!("/var/lib/archipelago/secrets/{}", name);
|
||||
if let Ok(val) = tokio::fs::read_to_string(&path).await {
|
||||
let trimmed = val.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
let mut buf = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
let secret = base64::engine::general_purpose::STANDARD.encode(buf);
|
||||
let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await;
|
||||
let _ = tokio::fs::write(&path, &secret).await;
|
||||
secret
|
||||
}
|
||||
|
||||
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
|
||||
/// resolver on this address, so nginx can use it as an explicit `resolver` to
|
||||
/// re-resolve container names at request time. Falls back to Podman's usual
|
||||
/// first-pool gateway if the inspect fails (best effort — config is rewritten
|
||||
/// on every (re)install).
|
||||
async fn netbird_net_resolver_ip() -> String {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"inspect",
|
||||
"netbird-net",
|
||||
"--format",
|
||||
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = out {
|
||||
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
||||
return gw;
|
||||
}
|
||||
}
|
||||
"10.89.0.1".to_string()
|
||||
}
|
||||
|
||||
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
|
||||
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
|
||||
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
|
||||
/// once when opening netbird in a tab). SAN covers the LAN IP plus
|
||||
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
|
||||
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
||||
let dir = "/var/lib/archipelago/netbird";
|
||||
let crt = format!("{dir}/tls.crt");
|
||||
let key = format!("{dir}/tls.key");
|
||||
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = tokio::fs::create_dir_all(dir).await;
|
||||
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
|
||||
let status = tokio::process::Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
&key,
|
||||
"-out",
|
||||
&crt,
|
||||
"-days",
|
||||
"3650",
|
||||
"-subj",
|
||||
&format!("/CN={host_ip}"),
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.status()
|
||||
.await
|
||||
.context("failed to run openssl for netbird TLS cert")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("openssl failed to generate netbird TLS cert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
|
||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
||||
// origin here is https (issue #15: over plain http the dashboard threw
|
||||
// "window.crypto.subtle is unavailable" and never reached login).
|
||||
let public_origin = format!("https://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
// A single box is reached via several addresses. Allow the OIDC login flow
|
||||
// to redirect back to whichever origin the user actually used, otherwise
|
||||
// post-login lands on the wrong host and the dashboard shows
|
||||
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
|
||||
// nginx proxy; this covers the redirect-URI allow-list.
|
||||
let lan_origin = format!("https://{}:8087", lan_ip);
|
||||
let mut redirect_origins = vec![public_origin.clone()];
|
||||
if lan_origin != public_origin {
|
||||
redirect_origins.push(lan_origin);
|
||||
}
|
||||
let dashboard_redirect_uris = redirect_origins
|
||||
.iter()
|
||||
.flat_map(|o| {
|
||||
[
|
||||
format!(" - \"{o}/nb-auth\""),
|
||||
format!(" - \"{o}/nb-silent-auth\""),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let dashboard_logout_uris = redirect_origins
|
||||
.iter()
|
||||
.map(|o| format!(" - \"{o}/\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
r#"server:
|
||||
listenAddress: ":80"
|
||||
exposedAddress: "{public_origin}"
|
||||
stunPorts:
|
||||
- 3478
|
||||
metricsPort: 9090
|
||||
healthcheckAddress: ":9000"
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
authSecret: "{relay_secret}"
|
||||
dataDir: "/var/lib/netbird"
|
||||
auth:
|
||||
issuer: "{public_origin}/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
{dashboard_redirect_uris}
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
{dashboard_logout_uris}
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
encryptionKey: "{encryption_key}"
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config)
|
||||
.await
|
||||
.context("Failed to write NetBird config.yaml")?;
|
||||
|
||||
let dashboard_env = format!(
|
||||
r#"NETBIRD_MGMT_API_ENDPOINT={public_origin}
|
||||
NETBIRD_MGMT_GRPC_API_ENDPOINT={public_origin}
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=
|
||||
AUTH_AUTHORITY={public_origin}/oauth2
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
NETBIRD_TOKEN_SOURCE=idToken
|
||||
NGINX_SSL_PORT=443
|
||||
LETSENCRYPT_DOMAIN=none
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env)
|
||||
.await
|
||||
.context("Failed to write NetBird dashboard.env")?;
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
|
||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||
# nginx resolves a literal upstream name ONCE at startup and caches it, so
|
||||
# after the IP moves every request 502s with "host unreachable" (issue #15,
|
||||
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
|
||||
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
|
||||
# use VARIABLE upstreams, which forces nginx to re-resolve the container
|
||||
# names at request time. Everything is reached container-to-container by
|
||||
# name so nothing depends on host-published ports either.
|
||||
resolver {resolver_ip} valid=10s ipv6=off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
|
||||
# to one host:port. A single box is reached via several addresses (LAN
|
||||
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
|
||||
# the browser blocks them with no Access-Control-Allow-Origin (issue
|
||||
# #15, observed live on .198). Reflect the caller's Origin so the
|
||||
# self-hosted management/OIDC API is reachable from any of them, and
|
||||
# answer the CORS preflight here.
|
||||
if ($request_method = OPTIONS) {{
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}}
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
||||
set $nb_server netbird-server;
|
||||
grpc_pass grpc://$nb_server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
|
||||
# the dashboard bundle, so proxying them straight through 404s — which
|
||||
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
|
||||
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
|
||||
# returned 404). Serve the dashboard's index.html at these paths (URL
|
||||
# unchanged) so react-oidc boots and completes the login / silent-SSO.
|
||||
location ~ ^/(nb-auth|nb-silent-auth) {{
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
rewrite ^.*$ /index.html break;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
# Direct server remains available for diagnostics at {server_origin}.
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/nginx.conf", nginx_conf)
|
||||
.await
|
||||
.context("Failed to write NetBird nginx.conf")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let ips: Vec<&str> = stdout
|
||||
.split_whitespace()
|
||||
.filter(|s| s.contains('.'))
|
||||
.collect();
|
||||
|
||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
||||
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
|
||||
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
|
||||
let is_private_lan = |ip: &str| {
|
||||
ip.starts_with("192.168.")
|
||||
|| ip.starts_with("10.")
|
||||
|| (ip.starts_with("172.")
|
||||
&& ip
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.and_then(|o| o.parse::<u8>().ok())
|
||||
.map(|o| (16..=31).contains(&o))
|
||||
.unwrap_or(false))
|
||||
};
|
||||
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
|
||||
return Some(lan.to_string());
|
||||
}
|
||||
ips.iter()
|
||||
.find(|ip| ip.starts_with("100."))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
||||
|
||||
@@ -32,19 +32,27 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Verify an update is actually available. Prefer the remote app catalog
|
||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
||||
// pin when the catalog is absent or doesn't cover this app.
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
// the image itself (manifest + catalog + version_config pin) in its
|
||||
// upgrade path, so an app the catalog doesn't carry a primary image for
|
||||
// (e.g. bitcoin-core, image lives in the embedded manifest + versions[])
|
||||
// still upgrades. Only the legacy/stack path below hard-requires it.
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
// `Updating`, so duplicating the check here would be a false positive.
|
||||
|
||||
install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await;
|
||||
install_log(&format!(
|
||||
"UPDATE: {} → {}",
|
||||
package_id,
|
||||
pinned.as_deref().unwrap_or("(orchestrator-resolved)")
|
||||
))
|
||||
.await;
|
||||
|
||||
// Set state to Updating
|
||||
{
|
||||
@@ -114,6 +122,16 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy/stack path hard-requires a concrete primary image (the
|
||||
// orchestrator path above already returned for apps it manages).
|
||||
let pinned = match pinned {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(anyhow::anyhow!("No pinned image found for {}", package_id));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
|
||||
@@ -154,18 +172,36 @@ impl RpcHandler {
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// package scanner recomputes each app's `available-update` from the fresh
|
||||
/// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
|
||||
/// failure leaves the cached catalog in place and reports `refreshed: false`.
|
||||
/// catalog on its next cycle and pushes it to the UI. When the catalog
|
||||
/// bytes changed, the orchestrator's manifest overlay is reloaded in the
|
||||
/// same call so catalog-shipped manifest fixes apply without a service
|
||||
/// restart. Best-effort — a fetch failure leaves the cached catalog in
|
||||
/// place and reports `refreshed: false`.
|
||||
pub(in crate::api::rpc) async fn handle_package_check_updates(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(count) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": count,
|
||||
})),
|
||||
Ok(refresh) => {
|
||||
let mut manifests_reloaded = serde_json::Value::Null;
|
||||
if refresh.changed {
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => manifests_reloaded = serde_json::json!(n),
|
||||
Err(e) => tracing::warn!(
|
||||
"check-updates: manifest reload after catalog change failed: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": refresh.apps,
|
||||
"catalog_changed": refresh.changed,
|
||||
"manifests_reloaded": manifests_reloaded,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
|
||||
@@ -26,6 +26,36 @@ impl Drop for OnboardingMnemonicState {
|
||||
|
||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||
|
||||
/// Persist the pending onboarding mnemonic as `identity/master_seed.enc`,
|
||||
/// encrypted with `passphrase`. Called from `auth.setup` — the first moment a
|
||||
/// user password exists — so "Reveal recovery phrase" works after onboarding
|
||||
/// without the frontend having to remember a separate save step (it never
|
||||
/// did, which left every onboarded node with no encrypted seed backup).
|
||||
///
|
||||
/// Deliberately ignores MNEMONIC_TTL: the mnemonic stays in memory until
|
||||
/// overwritten regardless, so using it here widens nothing, and onboarding
|
||||
/// legitimately takes longer than 10 minutes when the user carefully writes
|
||||
/// down 24 words. Clears the in-memory copy on success — password setup is
|
||||
/// the end of onboarding, so the plaintext no longer needs to linger.
|
||||
///
|
||||
/// Returns Ok(true) if a seed was saved, Ok(false) if none was pending.
|
||||
pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||
data_dir: &std::path::Path,
|
||||
passphrase: &str,
|
||||
) -> Result<bool> {
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
let Some(pending) = state.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mnemonic: bip39::Mnemonic = pending
|
||||
.words
|
||||
.parse()
|
||||
.context("Invalid mnemonic in memory")?;
|
||||
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
||||
*state = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||
@@ -208,6 +238,17 @@ impl RpcHandler {
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
@@ -329,14 +370,6 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required to reveal the recovery phrase");
|
||||
}
|
||||
|
||||
// Nothing to reveal if this node never stored an encrypted seed.
|
||||
if !crate::seed::seed_exists(&self.config.data_dir) {
|
||||
@@ -346,13 +379,57 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Re-authenticate with the login password.
|
||||
let mut password = self
|
||||
.verify_reveal_auth(¶ms, "the recovery phrase")
|
||||
.await?;
|
||||
|
||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
||||
// login password, so accept an explicit one and fall back to the
|
||||
// password when the user used the same value for both.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = reveal.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
|
||||
/// Re-authenticate a sensitive reveal: verify the login password from
|
||||
/// `params.password` and, when 2FA is enabled, require a valid
|
||||
/// replay-protected TOTP code from `params.code`. Returns the verified
|
||||
/// password (some callers also use it as a decryption passphrase); the
|
||||
/// caller must zeroize it. `what` names the secret in error messages,
|
||||
/// e.g. "the recovery phrase".
|
||||
pub(in crate::api::rpc) async fn verify_reveal_auth(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
what: &str,
|
||||
) -> Result<String> {
|
||||
let mut password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required to reveal {what}");
|
||||
}
|
||||
|
||||
if !self.auth_manager.verify_password(&password).await? {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Incorrect password");
|
||||
}
|
||||
|
||||
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
|
||||
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
|
||||
let code = params
|
||||
.get("code")
|
||||
@@ -361,7 +438,7 @@ impl RpcHandler {
|
||||
.to_string();
|
||||
if code.is_empty() {
|
||||
password.zeroize();
|
||||
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
|
||||
anyhow::bail!("A 2FA code is required to reveal {what}");
|
||||
}
|
||||
let totp_data = self
|
||||
.auth_manager
|
||||
@@ -390,25 +467,6 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
||||
// login password, so accept an explicit one and fall back to the
|
||||
// password when the user used the same value for both.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = reveal.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
Ok(password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,17 @@ impl RpcHandler {
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the self-signed HTTPS cert's SAN in sync with the new hostname —
|
||||
// best-effort, never blocks the rename itself. Without this the cert
|
||||
// stays pinned to whatever name was set at install time, so browsers
|
||||
// hit a hostname-mismatch warning on top of the usual self-signed one
|
||||
// the moment a node is renamed.
|
||||
if hostname_updated {
|
||||
if let Err(e) = regenerate_tls_cert(&hostname).await {
|
||||
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
@@ -66,6 +77,73 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// server.set-location — Set this node's own lat/lon + whether to share
|
||||
/// it with trusted federation peers (for the Mesh Map). `lat`/`lon` are
|
||||
/// optional so a caller can flip `share` off without clearing the saved
|
||||
/// position, or clear the position by passing nulls.
|
||||
pub(in crate::api::rpc) async fn handle_server_set_location(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let lat = params.get("lat").and_then(|v| v.as_f64());
|
||||
let lon = params.get("lon").and_then(|v| v.as_f64());
|
||||
let share_location = params
|
||||
.get("share")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: share"))?;
|
||||
|
||||
if let (Some(lat), Some(lon)) = (lat, lon) {
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
anyhow::bail!("Invalid lat/lon");
|
||||
}
|
||||
}
|
||||
|
||||
let location_file = self.config.data_dir.join("server-location.json");
|
||||
let payload =
|
||||
serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
|
||||
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
|
||||
.await
|
||||
.context("Failed to write server location")?;
|
||||
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.lat = lat;
|
||||
data.server_info.lon = lon;
|
||||
data.server_info.share_location = share_location;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
info!(share_location, "Server location updated");
|
||||
|
||||
// Push the new location to federation peers in background, same as
|
||||
// a rename — trusted peers' next state sync picks it up.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation location push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location }))
|
||||
}
|
||||
|
||||
/// system.get-hostname — Current OS hostname + the mDNS `.local` name it
|
||||
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
||||
/// Lets Settings show users where to reach this node over HTTPS for
|
||||
/// features (mic/camera access) that require a secure context.
|
||||
pub(in crate::api::rpc) async fn handle_system_get_hostname(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let hostname = tokio::fs::read_to_string("/etc/hostname")
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
@@ -319,6 +397,64 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regenerate the self-signed HTTPS cert (`/etc/archipelago/ssl/archipelago.{crt,key}`)
|
||||
/// with a SAN covering `hostname`, `hostname.local`, `localhost`, and 127.0.0.1, then
|
||||
/// reload nginx so it picks up the new cert. Still self-signed (browsers will warn
|
||||
/// on first visit regardless), but avoids stacking a hostname-mismatch warning on
|
||||
/// top once a node has been renamed away from the install-time default.
|
||||
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
|
||||
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
|
||||
let san =
|
||||
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"/usr/bin/openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"3650",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
"/etc/archipelago/ssl/archipelago.key",
|
||||
"-out",
|
||||
"/etc/archipelago/ssl/archipelago.crt",
|
||||
"-subj",
|
||||
&subj,
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run openssl")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
if stderr.is_empty() {
|
||||
"openssl cert regen failed".to_string()
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let reload = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/systemctl", "reload", "nginx"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to reload nginx")?;
|
||||
if !reload.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&reload.stderr).trim().to_string();
|
||||
anyhow::bail!("nginx reload failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
@@ -443,7 +579,7 @@ impl RpcHandler {
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
@@ -16,19 +16,21 @@ impl RpcHandler {
|
||||
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
|
||||
// pre-check) see funds available across BOTH backends (#3). Best-effort:
|
||||
// if fmcd isn't installed/joined this is just 0, never an error.
|
||||
let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir)
|
||||
.await
|
||||
{
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let fedimint_sats =
|
||||
match fedimint_client::FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
// Spendable Ark (barkd) balance, same best-effort contract.
|
||||
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
// spendable amount across Cashu + Fedimint + Ark.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats,
|
||||
"ark_sats": ark_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats + ark_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
@@ -182,6 +184,8 @@ impl RpcHandler {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let mut transactions = wallet.transactions;
|
||||
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
|
||||
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
|
||||
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||
// same-offset RFC-3339), newest first.
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Cross-layer registry of per-app lifecycle-operation locks and stack
|
||||
//! membership.
|
||||
//!
|
||||
//! The RPC layer's package.start/stop/restart workers serialize through
|
||||
//! these locks (FIFO, see api::rpc::package::runtime). Background actors
|
||||
//! (the reconciler; eventually the health monitor) must NOT act on an app
|
||||
//! while a lifecycle op is mid-sequence: the reconciler once saw a stack
|
||||
//! member "missing" between a restart worker's stop and start halves and
|
||||
//! repair-recreated it behind systemd's back, killing the worker's fresh
|
||||
//! container and leaving the unit down for minutes (.228 mempool frontend,
|
||||
//! gate 2026-07-09). This module lives outside both layers so each can
|
||||
//! consult the same state without an api ↔ container dependency cycle.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
static APP_OP_LOCKS: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// The per-app lifecycle-operation lock for a (normalized) app key. Workers
|
||||
/// take this as their first await; tokio's Mutex is fair (FIFO), so queued
|
||||
/// operations run in RPC arrival order and the final state matches the last
|
||||
/// request.
|
||||
pub fn op_lock(app_key: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
APP_OP_LOCKS
|
||||
.lock()
|
||||
.expect("APP_OP_LOCKS poisoned")
|
||||
.entry(app_key.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Member APP ids (start order) for orchestrator-managed stacks. Every entry
|
||||
/// is a real manifest app id the orchestrator can `start()`/`stop()` so the
|
||||
/// quadlet .service is driven instead of raw podman racing systemd's --rm
|
||||
/// cleanup. Single source of truth — the RPC layer re-exports this.
|
||||
pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"immich" => &["immich-postgres", "immich-redis", "immich"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mempool-api", "archy-mempool-web"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependents that resolve a backend's container address once at startup and
|
||||
/// hold it: moving the backend's IP (restart OR recreate) strands them until
|
||||
/// they restart too. lnd dials the bitcoin RPC address it resolved at boot
|
||||
/// and never re-resolves (gate lnd getinfo test, .228 2026-07-09; hardening
|
||||
/// plan §C). The RPC start/restart workers and the reconciler both consult
|
||||
/// this — single source of truth, like the stack table above.
|
||||
pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => &["lnd"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The package whose lifecycle lock covers `app_id`: the stack package when
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
|
||||
for stack in STACKS {
|
||||
if stack_member_app_ids(stack).contains(&app_id) {
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
app_id
|
||||
}
|
||||
|
||||
/// True when a package.start/stop/restart worker currently holds the
|
||||
/// lifecycle lock covering `app_id` (under its own key or its owning stack
|
||||
/// package's key). Background actors use this to skip the app for a cycle
|
||||
/// instead of interleaving with the worker's multi-step sequence. try_lock
|
||||
/// on a fair tokio Mutex is non-blocking and does not queue.
|
||||
pub fn lifecycle_op_in_flight(app_id: &str) -> bool {
|
||||
let keys = [app_id, owning_package(app_id)];
|
||||
for key in keys {
|
||||
let lock = op_lock(key);
|
||||
let held = lock.try_lock().is_err();
|
||||
if held {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn owning_package_maps_members_to_stack() {
|
||||
assert_eq!(owning_package("archy-mempool-web"), "mempool");
|
||||
assert_eq!(owning_package("immich-postgres"), "immich");
|
||||
assert_eq!(owning_package("indeedhub-relay"), "indeedhub");
|
||||
assert_eq!(owning_package("archy-nbxplorer"), "btcpay-server");
|
||||
assert_eq!(owning_package("lnd"), "lnd");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_flight_reflects_held_package_lock() {
|
||||
assert!(!lifecycle_op_in_flight("archy-mempool-web"));
|
||||
let lock = op_lock("mempool");
|
||||
let _guard = lock.lock().await;
|
||||
assert!(lifecycle_op_in_flight("archy-mempool-web"));
|
||||
assert!(lifecycle_op_in_flight("mempool"));
|
||||
assert!(!lifecycle_op_in_flight("jellyfin"));
|
||||
}
|
||||
}
|
||||
@@ -101,19 +101,45 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
"verifying blocks after restart"
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
|
||||
"waiting for the Bitcoin RPC listener"
|
||||
Some("waiting for the Bitcoin RPC listener")
|
||||
} else if lower.contains("timed out") || lower.contains("timeout") {
|
||||
"busy and not answering RPC before the timeout"
|
||||
Some("busy and not answering RPC before the timeout")
|
||||
} else {
|
||||
"starting or busy syncing"
|
||||
None
|
||||
};
|
||||
|
||||
if has_cached_state {
|
||||
format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
|
||||
// Recognized transient causes get a clean human sentence only — the raw
|
||||
// transport error (URLs, repeated "os error 104" chains) is operator
|
||||
// noise that was ending up verbatim on the app card. Unrecognized errors
|
||||
// keep a bounded detail so a genuinely new failure stays diagnosable.
|
||||
let (state, detail) = match state {
|
||||
Some(state) => (state, None),
|
||||
None => (
|
||||
"starting or busy syncing",
|
||||
Some(if detail.len() > 120 {
|
||||
let mut cut = 120;
|
||||
while !detail.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
format!("{}…", &detail[..cut])
|
||||
} else {
|
||||
detail.to_string()
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
let base = if has_cached_state {
|
||||
format!("Bitcoin node is {state}; showing last known state and retrying.")
|
||||
} else {
|
||||
format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
|
||||
format!("Bitcoin node is {state}; retrying automatically.")
|
||||
};
|
||||
match detail {
|
||||
Some(detail) => format!("{base} Detail: {detail}"),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,4 +304,39 @@ mod tests {
|
||||
|
||||
assert!(msg.contains("busy and not answering RPC before the timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_reset_gets_clean_message_without_raw_detail() {
|
||||
// The exact string a fresh install showed on the app card: the raw
|
||||
// reqwest chain (URL + repeated "os error 104") must not surface.
|
||||
let msg = friendly_transient_error(
|
||||
false,
|
||||
"getblockchaininfo: Bitcoin RPC request failed: error sending request for url (http://127.0.0.1:8332/): connection error: Connection reset by peer (os error 104): connection error: Connection reset by peer (os error 104): Connection reset by peer (os error 104)",
|
||||
);
|
||||
|
||||
assert!(msg.contains("starting up and not yet accepting RPC connections"));
|
||||
assert!(!msg.contains("os error"));
|
||||
assert!(!msg.contains("127.0.0.1"));
|
||||
assert!(!msg.contains("Detail:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_causes_omit_detail_entirely() {
|
||||
for raw in [
|
||||
"x: Connection refused (os error 111)",
|
||||
"x: operation timed out",
|
||||
r#"x: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
|
||||
] {
|
||||
let msg = friendly_transient_error(false, raw);
|
||||
assert!(!msg.contains("Detail:"), "leaked detail for: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_errors_keep_bounded_detail() {
|
||||
let long = format!("weird new failure {}", "x".repeat(300));
|
||||
let msg = friendly_transient_error(false, &long);
|
||||
assert!(msg.contains("Detail: weird new failure"));
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
// A fresh node produced >1 GB/day of journal (bitcoind IBD console spam plus
|
||||
// debug-level backend logging) — the cap bounds disk use and the rate limit
|
||||
// keeps one chatty service from drowning everything else.
|
||||
const JOURNALD_DROPIN: &str =
|
||||
include_str!("../../../image-recipe/configs/journald-archipelago.conf");
|
||||
const JOURNALD_DROPIN_PATH: &str = "/etc/systemd/journald.conf.d/10-archipelago-persistent.conf";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||
@@ -120,6 +130,18 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_journald_dropin().await {
|
||||
Ok(true) => info!("Installed journald log-volume policy drop-in"),
|
||||
Ok(false) => debug!("journald log-volume policy already in place"),
|
||||
Err(e) => warn!("journald drop-in bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match tighten_secrets_dir().await {
|
||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||
@@ -387,6 +409,39 @@ fn path_dot(path: &Path) -> String {
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// ISO installs before the auto-install.sh path fix copied the app manifests
|
||||
/// to /etc/archipelago/apps while the backend loads them from
|
||||
/// /opt/archipelago/apps — so fresh nodes had ZERO disk manifests and only
|
||||
/// catalog-covered apps could install (netbird "manifests not available",
|
||||
/// framework node 2026-07-14). Self-heal: when /opt has no manifests and the
|
||||
/// installer copy exists, populate /opt from /etc. Never overwrites existing
|
||||
/// /opt manifests (OTA runtime-assets sync owns those afterwards).
|
||||
async fn run_apps_dir_repair() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
src=/etc/archipelago/apps
|
||||
dst=/opt/archipelago/apps
|
||||
[ -d "$src" ] || exit 0
|
||||
# Only heal when the destination has no manifests at all.
|
||||
if [ -d "$dst" ] && [ -n "$(ls -A "$dst" 2>/dev/null)" ]; then exit 0; fi
|
||||
ls "$src"/*/manifest.yml >/dev/null 2>&1 || exit 0
|
||||
mkdir -p "$dst"
|
||||
cp -r "$src"/. "$dst"/
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("populate /opt/archipelago/apps from installer copy")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Apps dir repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
@@ -406,8 +461,22 @@ ensure_line() {
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
|
||||
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
|
||||
# dial over the container network (LND, bitcoin-ui) is refused — the fresh-
|
||||
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
|
||||
# 127.0.0.1-only, so exposure is unchanged).
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
|
||||
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
|
||||
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
|
||||
# self-shrinks it on restart).
|
||||
if grep -q '^printtoconsole=1' "$conf"; then
|
||||
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
|
||||
changed=1
|
||||
fi
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
exit 2
|
||||
"#;
|
||||
@@ -428,6 +497,44 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the journald log-volume policy drop-in (JOURNALD_DROPIN) so nodes
|
||||
/// deployed before the ISO shipped it get the size cap + rate limit via OTA.
|
||||
/// Idempotent; restarts journald only when the file actually changed (safe:
|
||||
/// the sockets are held by pid1, so at most a few messages queue briefly).
|
||||
async fn run_journald_dropin() -> Result<bool> {
|
||||
// Same dev-box guards as the doctor bootstrap: never touch /etc on
|
||||
// contributors' laptops (symlinked or absent /home/archipelago/archy).
|
||||
let home_archy = Path::new("/home/archipelago/archy");
|
||||
if fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
debug!("/home/archipelago/archy is a symlink — skipping journald bootstrap (dev box)");
|
||||
return Ok(false);
|
||||
}
|
||||
if fs::metadata(home_archy).await.is_err() {
|
||||
debug!("/home/archipelago/archy missing — skipping journald bootstrap");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let dropin_dir = "/etc/systemd/journald.conf.d";
|
||||
let status = host_sudo(&["mkdir", "-p", dropin_dir])
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", dropin_dir))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("mkdir {} exited with {}", dropin_dir, status);
|
||||
}
|
||||
|
||||
let changed = write_root_if_needed(JOURNALD_DROPIN_PATH, JOURNALD_DROPIN).await?;
|
||||
if changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "restart", "systemd-journald"]).await {
|
||||
warn!("journald restart after drop-in update failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn run() -> Result<bool> {
|
||||
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
||||
// typically a symlink into the git checkout, and writing through it
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||
//! what `trust::verify_detached` recomputes on every node.
|
||||
//!
|
||||
//! archipelago ceremony verify <file.json>
|
||||
//! Verify a signed JSON document against the compiled-in release-root
|
||||
//! anchor. Exits non-zero unless the signature verifies AND the signer
|
||||
//! is the pinned anchor. Needs no mnemonic — used as the publish gate.
|
||||
//! ```
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -47,9 +52,15 @@ pub fn run() -> Result<()> {
|
||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||
cmd_sign(&file)
|
||||
}
|
||||
"verify" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony verify <file.json>")?;
|
||||
cmd_verify(&file)
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>",
|
||||
other
|
||||
)
|
||||
}
|
||||
@@ -107,6 +118,33 @@ fn cmd_sign(path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_verify(path: &str) -> Result<()> {
|
||||
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||
match signed_doc::verify_detached(&value)? {
|
||||
signed_doc::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored: true,
|
||||
} => {
|
||||
eprintln!("✓ {path} verified — signed by the pinned release root");
|
||||
eprintln!(" signed_by: {signer_did}");
|
||||
Ok(())
|
||||
}
|
||||
signed_doc::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored: false,
|
||||
} => {
|
||||
// Only reachable if no anchor is compiled in/overridden — the
|
||||
// signature is self-consistent but proves nothing about identity.
|
||||
bail!("{path} signed by {signer_did}, but no release-root anchor is pinned to compare against")
|
||||
}
|
||||
signed_doc::SignatureStatus::Unsigned => {
|
||||
bail!("{path} is NOT signed (no `signature` field)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||
fn load_release_root_key() -> Result<SigningKey> {
|
||||
let phrase = read_mnemonic()?;
|
||||
|
||||
@@ -80,18 +80,11 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Detect primary host IP (first non-loopback IPv4)
|
||||
fn detect_host_ip() -> Result<String> {
|
||||
let output = std::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
.context("Failed to run hostname -I")?;
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
let ip = s
|
||||
.split_whitespace()
|
||||
.find(|s| !s.starts_with("127.") && s.contains('.'))
|
||||
.unwrap_or("127.0.0.1");
|
||||
Ok(ip.to_string())
|
||||
/// Detect primary host IP (default-route interface, not `hostname -I` order)
|
||||
async fn detect_host_ip() -> Result<String> {
|
||||
Ok(crate::host_ip::primary_host_ipv4()
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()))
|
||||
}
|
||||
|
||||
pub async fn load() -> Result<Self> {
|
||||
@@ -210,7 +203,9 @@ impl Config {
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
} else {
|
||||
config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
config.host_ip = Self::detect_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user