Compare commits
134
Commits
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep the served companion APK in sync with main on every push.
|
||||
#
|
||||
# When a push to main includes Android changes, rebuild the APK, refresh
|
||||
# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask
|
||||
# you to push again (so the refreshed APK rides along in the same push).
|
||||
#
|
||||
# Enable once per clone: git config core.hooksPath .githooks
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
# ship-companion.sh already (re)published the APK for this push — don't redo it.
|
||||
[ -n "${SHIP_COMPANION:-}" ] && exit 0
|
||||
|
||||
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
|
||||
while read -r _local_ref local_sha remote_ref remote_sha; do
|
||||
if [ "${remote_ref##*/}" = "main" ]; then
|
||||
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
|
||||
fi
|
||||
done
|
||||
[ "$PUSH_MAIN" = "1" ] || exit 0
|
||||
|
||||
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
|
||||
case "$(git log -1 --pretty=%s)" in
|
||||
*"companion APK"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Only rebuild when this push actually touches the Android app.
|
||||
ZEROS="0000000000000000000000000000000000000000"
|
||||
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
|
||||
ANDROID_CHANGED=1
|
||||
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
|
||||
ANDROID_CHANGED=0
|
||||
else
|
||||
ANDROID_CHANGED=1
|
||||
fi
|
||||
[ "$ANDROID_CHANGED" = "1" ] || exit 0
|
||||
|
||||
bash scripts/publish-companion-apk.sh || exit 0
|
||||
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
||||
if git diff --cached --quiet -- "$DEST"; then
|
||||
exit 0 # APK unchanged — nothing to do
|
||||
fi
|
||||
|
||||
git commit -q -m "chore(android): update companion APK download [skip ci]"
|
||||
echo "" >&2
|
||||
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
|
||||
exit 1
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 6
|
||||
versionName = "0.4.2"
|
||||
versionCode = 12
|
||||
versionName = "0.4.8"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -20,6 +20,12 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Separate app ID so a debug/test build installs alongside the
|
||||
// release app instead of colliding on signature.
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
|
||||
@@ -18,7 +18,11 @@ data class ServerEntry(
|
||||
val useHttps: Boolean,
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
) {
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
@@ -31,7 +35,9 @@ data class ServerEntry(
|
||||
return "$scheme://$address$portSuffix"
|
||||
}
|
||||
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password"
|
||||
// name is the trailing field so entries saved before naming existed
|
||||
// (4 fields) still deserialize, with name defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
@@ -42,6 +48,7 @@ data class ServerEntry(
|
||||
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
name = parts.getOrElse(4) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
|
||||
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val activeNameKey = stringPreferencesKey("active_name")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
|
||||
@@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
name = prefs[activeNameKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activeHttpsKey] = server.useHttps
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
prefs[activeNameKey] = server.name
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
@@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs.remove(activeHttpsKey)
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
prefs.remove(activeNameKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) {
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
prefs[savedServersKey] = current - server.serialize()
|
||||
// Match by connection identity (address/port/scheme) rather than the
|
||||
// exact serialized string, so a rename — or the legacy 4-field format
|
||||
// saved before names existed — still removes the right entry.
|
||||
prefs[savedServersKey] = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
|
||||
.pointerInput(key) {
|
||||
detectTapGestures(onPress = {
|
||||
p = true; onDir(key)
|
||||
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
|
||||
// 500ms initial delay so a normal tap sends one key, not two
|
||||
// (a touch tap often exceeds 350ms → doubled nav sound).
|
||||
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
|
||||
tryAwaitRelease(); p = false; job?.cancel()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -83,13 +83,16 @@ val ClassicPalette = NESPalette(
|
||||
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
||||
)
|
||||
|
||||
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
|
||||
// shows through the controller, subtle white-alpha borders, translucent-white
|
||||
// buttons. Accents come from each button's ring.
|
||||
val DarkPalette = NESPalette(
|
||||
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
|
||||
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
|
||||
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
|
||||
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
|
||||
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
|
||||
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
|
||||
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
|
||||
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
|
||||
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
|
||||
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
|
||||
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
|
||||
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
|
||||
)
|
||||
|
||||
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
||||
@@ -113,20 +116,10 @@ fun NESController(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Shadow platform
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color(0xFF000000)),
|
||||
)
|
||||
// Controller body
|
||||
Box(
|
||||
Modifier
|
||||
@@ -135,7 +128,7 @@ fun NESController(
|
||||
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
|
||||
Brush.verticalGradient(listOf(c.body, c.body))
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
@@ -193,13 +186,13 @@ fun NESController(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// C on top (white)
|
||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
|
||||
// C on top
|
||||
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// B + A on bottom row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
|
||||
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +257,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
|
||||
}
|
||||
activeDir = dir; onDir(dir)
|
||||
job?.cancel()
|
||||
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
|
||||
// 500ms initial delay so a normal tap sends one key, not
|
||||
// two (a touch tap often exceeds 300ms → doubled nav sound).
|
||||
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
|
||||
tryAwaitRelease()
|
||||
job?.cancel(); activeDir = null
|
||||
},
|
||||
@@ -375,6 +370,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
|
||||
@Composable
|
||||
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
Modifier
|
||||
.size(sz)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
|
||||
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
|
||||
)
|
||||
)
|
||||
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
/** START/SELECT capsule */
|
||||
@Composable
|
||||
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -34,17 +36,35 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/** NES-styled modal menu — dark blue panel with white borders */
|
||||
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
|
||||
// Bitcoin-orange accent.
|
||||
private val PanelBg = SurfaceDark // #0A0A0A
|
||||
private val PanelBorder = Color.White.copy(alpha = 0.12f)
|
||||
private val RowBg = Color.White.copy(alpha = 0.05f)
|
||||
private val RowBorder = Color.White.copy(alpha = 0.08f)
|
||||
private val FieldBg = Color.White.copy(alpha = 0.04f)
|
||||
|
||||
private val PANEL_R = 20.dp
|
||||
private val ROW_R = 14.dp
|
||||
private val ROW_H = 54.dp
|
||||
private val FIELD_H = 58.dp
|
||||
|
||||
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
|
||||
@Composable
|
||||
fun NESMenu(
|
||||
visible: Boolean,
|
||||
@@ -66,7 +86,9 @@ fun NESMenu(
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,29 +108,45 @@ private fun MenuPanel(
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var nm by remember { mutableStateOf("") }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
|
||||
fun submit() {
|
||||
if (addr.isNotBlank()) {
|
||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NES.MenuPanel)
|
||||
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
|
||||
.widthIn(max = 420.dp)
|
||||
.padding(horizontal = 20.dp)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg)
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Title
|
||||
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
|
||||
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Menu",
|
||||
color = TextPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Servers
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = (if (active) "\u25B6 " else " ") + server.address,
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
@@ -116,69 +154,70 @@ private fun MenuPanel(
|
||||
}
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add server
|
||||
if (showAdd) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
GlassField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = "192.168.1.100",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlassField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
|
||||
placeholder = "Password",
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
}),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
|
||||
.clickable {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
},
|
||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { submit() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
|
||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Mode toggle
|
||||
MenuItem(
|
||||
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
|
||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||
onClick = onToggleMode,
|
||||
)
|
||||
|
||||
// Style toggle
|
||||
MenuItem(
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||
onClick = onToggleStyle,
|
||||
)
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
|
||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,32 +226,69 @@ private fun MenuPanel(
|
||||
private fun MenuItem(
|
||||
label: String,
|
||||
selected: Boolean = false,
|
||||
labelColor: Color = TextPrimary,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
|
||||
.height(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
|
||||
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (onRemove != null) {
|
||||
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
|
||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
"✕",
|
||||
color = TextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Glass text field with centered input text. */
|
||||
@Composable
|
||||
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NES.MenuBorder,
|
||||
unfocusedBorderColor = NES.MenuMuted,
|
||||
cursorColor = NES.MenuText,
|
||||
focusedTextColor = NES.MenuText,
|
||||
unfocusedTextColor = NES.MenuText,
|
||||
)
|
||||
private fun GlassField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = {
|
||||
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth().height(FIELD_H),
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-5
@@ -50,7 +50,6 @@ fun NESPortraitController(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -62,7 +61,7 @@ fun NESPortraitController(
|
||||
.fillMaxSize()
|
||||
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body)))
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
// Top highlight
|
||||
@@ -119,11 +118,11 @@ fun NESPortraitController(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
|
||||
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
|
||||
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -41,7 +42,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.Center,
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
// Reddish synthwave backdrop
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Dark scrim so the title/buttons stay legible over the art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.55f),
|
||||
Color.Black.copy(alpha = 0.35f),
|
||||
Color.Black.copy(alpha = 0.75f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Wide pixel-art logo
|
||||
// Circular badge logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(160.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
@@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = TextPrimary,
|
||||
color = Color(0xFFFAFAFA),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = TextMuted,
|
||||
color = Color(0xFFFAFAFA),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 26.sp,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,13 +25,17 @@ 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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
@@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||
|
||||
val ws = remember { InputWebSocket(scope) }
|
||||
@@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
.background(Color(0xFF0C0C0C)),
|
||||
) {
|
||||
// Reddish synthwave backdrop behind the controller
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Light scrim — the controller body provides its own contrast, so keep
|
||||
// this subtle and let the backdrop show through around it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.4f),
|
||||
Color.Black.copy(alpha = 0.25f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
|
||||
when {
|
||||
isGamepadMode && isLandscape -> NESController(
|
||||
style = controllerStyle,
|
||||
@@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
NESMenu(
|
||||
visible = showModal,
|
||||
@@ -188,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
|
||||
onRemoveServer = { server ->
|
||||
scope.launch {
|
||||
prefs.removeSavedServer(server)
|
||||
// Deleting the last server leaves nothing to control — drop the
|
||||
// active server and return to the Connect screen.
|
||||
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||
if (remaining == 0) {
|
||||
ws.disconnect()
|
||||
prefs.clearActiveServer()
|
||||
showModal = false
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
onToggleStyle = {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -97,6 +98,7 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -132,12 +134,33 @@ fun ServerConnectScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
// Reddish synthwave backdrop
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Dark scrim so the form stays legible over the art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
Color.Black.copy(alpha = 0.8f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(state = rememberScrollState())
|
||||
.drawWithContent { drawContent() }
|
||||
.padding(horizontal = 24.dp)
|
||||
@@ -145,14 +168,11 @@ fun ServerConnectScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Wide logo
|
||||
// Circular badge logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -178,6 +198,7 @@ fun ServerConnectScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
@@ -190,6 +211,34 @@ fun ServerConnectScreen(
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.server_name_label)) },
|
||||
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = Color.White,
|
||||
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = {
|
||||
@@ -275,7 +324,7 @@ fun ServerConnectScreen(
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -345,7 +394,7 @@ fun ServerConnectScreen(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
@@ -391,6 +440,7 @@ private fun SavedServerItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
@@ -414,9 +464,15 @@ private fun SavedServerItem(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (server.port.isNotBlank()) {
|
||||
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
|
||||
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val secondary = buildString {
|
||||
if (server.name.isNotBlank()) append(server.address)
|
||||
if (server.port.isNotBlank()) {
|
||||
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
|
||||
}
|
||||
}
|
||||
if (secondary.isNotBlank()) {
|
||||
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
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.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
|
||||
@@ -18,6 +32,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -27,9 +42,24 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.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.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -41,8 +71,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.archipelago.app.R
|
||||
@@ -51,7 +83,220 @@ import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder thread
|
||||
// (the JS bridge below can run off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/** True when [url] points at the same host as the connected Archipelago node
|
||||
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
|
||||
* and should stay inside the app rather than bouncing out to the browser. */
|
||||
private fun isSameHost(url: String, base: String): Boolean {
|
||||
return try {
|
||||
val a = android.net.Uri.parse(url).host ?: return false
|
||||
val b = android.net.Uri.parse(base).host ?: return false
|
||||
a.equals(b, ignoreCase = true)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
* These are tuned for SPA performance and parity with the mobile browser;
|
||||
* none of them alter how a page renders visually. */
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun WebView.applyArchipelagoSettings() {
|
||||
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
|
||||
// show blank checkerboarding — the single biggest scroll-smoothness win and
|
||||
// a major part of the "feels slower than the browser" gap. (API 23+)
|
||||
settings.setOffscreenPreRaster(true)
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
}
|
||||
|
||||
// chrome://inspect profiling on debuggable builds only — lets us measure the
|
||||
// real in-page bottleneck rather than guess. No effect on release builds.
|
||||
val debuggable = 0 != (context.applicationInfo.flags and
|
||||
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
|
||||
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(
|
||||
serverUrl: String,
|
||||
@@ -63,7 +308,43 @@ fun WebViewScreen(
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
||||
// 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.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
@@ -132,20 +413,6 @@ fun WebViewScreen(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
fun openExternalUrl(url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -159,19 +426,9 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
enableFileDownloads(context)
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
@@ -179,18 +436,35 @@ fun WebViewScreen(
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
|
||||
// Decide where an outbound URL goes:
|
||||
// - same host as the node → in-app WebView overlay
|
||||
// (this is the "open in browser" target for apps the
|
||||
// kiosk couldn't iframe — keep the user inside the app)
|
||||
// - different host → the phone's real browser
|
||||
fun routeOutbound(url: String) {
|
||||
if (isSameHost(url, serverUrl)) {
|
||||
inAppUrl = url
|
||||
} else {
|
||||
openExternalUrl(context, url)
|
||||
}
|
||||
}
|
||||
|
||||
// JS bridge. The web UI calls:
|
||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||
// Falls back to window.open in a plain mobile browser.
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
webViewRef.post { routeOutbound(url) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { inAppUrl = url }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
@@ -252,10 +526,10 @@ fun WebViewScreen(
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep navigation within the Archipelago server
|
||||
// Keep kiosk navigation (same origin incl. port) in place
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Open external URLs in the system browser
|
||||
openExternalUrl(url)
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -265,7 +539,16 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Handle window.open() — open in system browser
|
||||
// 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.
|
||||
override fun onCreateWindow(
|
||||
view: WebView?,
|
||||
isDialog: Boolean,
|
||||
@@ -283,12 +566,12 @@ fun WebViewScreen(
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
openExternalUrl(url)
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
if (url != null) openExternalUrl(url)
|
||||
if (url != null) routeOutbound(url)
|
||||
view?.stopLoading()
|
||||
}
|
||||
}
|
||||
@@ -350,6 +633,233 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||
inAppUrl?.let { target ->
|
||||
InAppBrowser(
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
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 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) }
|
||||
|
||||
LaunchedEffect(url) {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
||||
if (fetched != null && appIcon == null) appIcon = fetched
|
||||
}
|
||||
|
||||
// Back: walk the in-app history first, then close the overlay.
|
||||
BackHandler {
|
||||
val b = browser
|
||||
if (b != null && b.canGoBack()) b.goBack() else onClose()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
// 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),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
isVerticalScrollBarEnabled = false
|
||||
isHorizontalScrollBarEnabled = false
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
enableFileDownloads(ctx)
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
override fun onReceivedTitle(view: WebView?, t: String?) {
|
||||
if (!t.isNullOrBlank()) title = t
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
if (icon != null) appIcon = 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
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val u = request?.url?.toString() ?: return false
|
||||
// Stay in the overlay for same-node navigation;
|
||||
// hand genuinely external links to the real browser.
|
||||
if (isSameHost(u, serverUrl)) return false
|
||||
openExternalUrl(ctx, u)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
browser = this
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Loading splash — app icon + progress bar, covering the white
|
||||
// first-paint flash until the app's own UI is ready.
|
||||
if (loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.padding(32.dp),
|
||||
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),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(0.55f),
|
||||
color = BitcoinOrange,
|
||||
trackColor = SurfaceBlack,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xF2141414)),
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -1,10 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Whole badge lives here (background renders to the mask edge with no
|
||||
safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled
|
||||
inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the
|
||||
locally-rendered preview. Foreground is transparent. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<path
|
||||
android:fillColor="#030202"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
android:fillColor="#0A0A0A"
|
||||
android:pathData="M0,0h752v752H0z" />
|
||||
|
||||
<!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at
|
||||
the home-screen's visible edge (calibrated from a device home screenshot;
|
||||
launcher3 crops less than the Settings App-info view). -->
|
||||
<group
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.65"
|
||||
android:scaleY="0.65">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0.000976562">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
|
||||
<!-- White Archipelago grid -->
|
||||
<group
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
|
||||
<!-- Transparent — the whole badge (ring + grid) is in the background layer so it
|
||||
renders to the mask edge without safe-zone cropping. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
|
||||
<!-- Row 1: 4 blocks -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
|
||||
<!-- Row 2: 2 blocks (right side) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
|
||||
<!-- Row 3: 6 blocks (full width) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
|
||||
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
|
||||
<!-- Row 5: 4 blocks (bottom) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
</group>
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago circular badge logo (from logo.svg):
|
||||
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
|
||||
<path
|
||||
android:fillColor="#0A0A0A"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- White Archipelago pixel grid -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M15,19l-7,-7 7,-7"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,18L18,6M6,6l12,12"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M9,5l7,7 -7,7"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -21,4 +21,11 @@
|
||||
<string name="retry">Retry</string>
|
||||
<string name="remote_input">Remote Control</string>
|
||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="open_in_browser">Open in browser</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="forward">Forward</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="server_name_label">Server Name (optional)</string>
|
||||
<string name="server_name_placeholder">My Archipelago</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
|
||||
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-color="#666666"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the Android companion app and publish it as the served download
|
||||
# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can
|
||||
# install straight from the link), then commit + push.
|
||||
#
|
||||
# Use this INSTEAD of `git push` when shipping the companion app, so the
|
||||
# downloadable APK on the node always matches what's on main.
|
||||
#
|
||||
# ./Android/ship-companion.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
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 "==> 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
|
||||
|
||||
git add "$DEST"
|
||||
if git diff --cached --quiet; then
|
||||
echo "==> Nothing to commit (working tree + APK unchanged)"
|
||||
else
|
||||
git commit -q -m "chore(android): update companion apk download"
|
||||
echo "==> Committed"
|
||||
fi
|
||||
|
||||
echo "==> Pushing $(git branch --show-current)"
|
||||
# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed.
|
||||
SHIP_COMPANION=1 git push origin "$(git branch --show-current)"
|
||||
echo "==> Done — companion APK published and pushed."
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.00-alpha (2026-06-18)
|
||||
|
||||
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).
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Archipelago — agent guide
|
||||
|
||||
## ✅ Single-node production gate is GREEN (2026-06-23)
|
||||
|
||||
`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit
|
||||
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.
|
||||
|
||||
**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),
|
||||
and **third-party developers publish apps via an external/decentralized registry** —
|
||||
all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all
|
||||
ad-hoc direction now that the gate is green, but it remains the source of truth for
|
||||
sequencing the remaining workstreams.
|
||||
|
||||
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`
|
||||
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
|
||||
|
||||
## Invariants (never violate)
|
||||
|
||||
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
|
||||
containers unless explicitly approved.
|
||||
- **No per-app Rust installers / no OS-level reliance.** Apps are declarative;
|
||||
the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded
|
||||
`podman run` + `sudo chown`) is the anti-pattern being deleted, not a template.
|
||||
- **Secrets are manifest-declared** (`generated_secrets`, materialised by
|
||||
`container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged.
|
||||
- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`,
|
||||
secrets, credentials, ports, and adoption container names; keep a rollback path.
|
||||
- **Verify on the real node .228 before any tag.** (Fleet-wide multinode
|
||||
verification is a separate plan: `docs/multinode-testing-plan.md`.)
|
||||
|
||||
## Build / verify
|
||||
|
||||
- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`.
|
||||
- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's
|
||||
incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
|
||||
- Frontend: `neode-ui/` → `npm run build` outputs to `web/dist/neode-ui/`.
|
||||
Grep the built bundle for new strings before shipping (build can silently no-op).
|
||||
- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml`
|
||||
(today); the goal is to distribute them via the signed catalog instead.
|
||||
|
||||
## Production test gate (definition of done)
|
||||
|
||||
`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart /
|
||||
reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on
|
||||
.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin
|
||||
probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it
|
||||
green (re-run after orchestrator/lifecycle changes); regressions are top priority again.
|
||||
**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan** —
|
||||
`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is
|
||||
the next exit criterion now that single-node is green.
|
||||
@@ -73,7 +73,7 @@
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint",
|
||||
"title": "Fedimint Guardian",
|
||||
"version": "0.10.0",
|
||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.0
|
||||
version: 3.0.1
|
||||
description: Frontend web UI for mempool explorer.
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
|
||||
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ app:
|
||||
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
|
||||
# unreachable default never crash-loops. All config comes from FMCD_* env
|
||||
# below. Nodes can join more federations via wallet.fedimint-join.
|
||||
# Auto-generated on first install (random hex, 0600, rootless-owned) so the
|
||||
# app needs no host provisioning. The wallet bridge reads the same file.
|
||||
generated_secrets:
|
||||
- name: fmcd-password
|
||||
kind: hex16
|
||||
secret_env:
|
||||
- key: FMCD_PASSWORD
|
||||
secret_file: fmcd-password
|
||||
@@ -33,12 +38,22 @@ app:
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
# fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on
|
||||
# every start. With the default `cap_drop: ALL` and no caps added back, that
|
||||
# chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY
|
||||
# once /data holds a joined federation (a fresh/empty dir needs no chown, so
|
||||
# it appeared to work). Restore the standard container capability set so the
|
||||
# startup chown succeeds (#7). Verified by bisection on .116: these caps make
|
||||
# fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT.
|
||||
capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"]
|
||||
readonly_root: true
|
||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||
# relays to reach iroh-transport federations. Lock down once the default
|
||||
# federation's reachability model is finalized.
|
||||
network_policy: open
|
||||
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
|
||||
# wallet bridge targets. ("open" is not a valid policy — it made the loader
|
||||
# skip this whole manifest, so fmcd never ran and federations never joined.)
|
||||
# Lock down once the default federation's reachability model is finalized.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||
@@ -66,10 +81,15 @@ app:
|
||||
# join reliability from a real second node before relying on auto-bundle.
|
||||
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
|
||||
|
||||
# fmcd serves only authenticated /v2/* routes — there is no unauthenticated
|
||||
# /health endpoint, so an http probe to /health 404s forever and pins the
|
||||
# container in "(starting)". fmcd's own image also ships neither curl nor wget.
|
||||
# Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the
|
||||
# host-side lifecycle layer verifies reachability, so the container reports
|
||||
# "running" instead of a perpetual false-negative "(starting)".
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /health
|
||||
type: tcp
|
||||
endpoint: localhost:8080
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -16,6 +16,14 @@ app:
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||
# the hash passed to gatewayd, `fedimint-gateway-hash.pw` the plaintext for
|
||||
# any client that must authenticate. Self-heals a wrongly root-owned hash.
|
||||
generated_secrets:
|
||||
- name: fedimint-gateway-hash
|
||||
kind: bcrypt
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
app:
|
||||
id: fedimint
|
||||
name: Fedimint
|
||||
name: Fedimint Guardian
|
||||
version: 0.10.0
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
app:
|
||||
id: immich-postgres
|
||||
name: Immich Postgres
|
||||
version: "14-vectorchord0.4.3-pgvectors0.2.0"
|
||||
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
|
||||
|
||||
# Container named immich_postgres (underscore) to match the runtime's existing
|
||||
# per-app references (lifecycle/health/crash-recovery/config) and serve as the
|
||||
# server's DB_HOSTNAME alias. Top-level key → serde(flatten) → extensions →
|
||||
# compute_container_name.
|
||||
container_name: immich_postgres
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# postgres drops to its own uid (container 999 → host 100998 under rootless),
|
||||
# so the data dir must be owned by that mapped uid — mirrors archy-btcpay-db.
|
||||
# Verified on .228: the live immich-db is owned 100998. Without this a FRESH
|
||||
# install's dir would be service-user-owned and postgres would EACCES.
|
||||
data_uid: "100998:100998"
|
||||
generated_secrets:
|
||||
- name: immich-db-password
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: POSTGRES_PASSWORD
|
||||
secret_file: immich-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 40Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 40Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/immich-db
|
||||
target: /var/lib/postgresql/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_DB=immich
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:5432
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,37 @@
|
||||
app:
|
||||
id: immich-redis
|
||||
name: Immich Redis
|
||||
version: "7-alpine"
|
||||
description: Valkey (Redis-compatible) cache for Immich.
|
||||
|
||||
# Container named immich_redis (underscore) to match runtime per-app references
|
||||
# and serve as the server's REDIS_HOSTNAME alias on archy-net.
|
||||
container_name: immich_redis
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/valkey:7-alpine
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
dependencies: []
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
capabilities: [SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:6379
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,74 @@
|
||||
app:
|
||||
id: immich
|
||||
name: Immich
|
||||
version: "2.7.4"
|
||||
description: Self-hosted photo and video backup with mobile apps and search.
|
||||
|
||||
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
|
||||
# + icon). The container is named "immich_server" so it matches the runtime's
|
||||
# existing per-app container references (lifecycle/health/crash-recovery/ports);
|
||||
# `container_name` is a top-level app key (captured by serde(flatten) into
|
||||
# extensions, read by compute_container_name). It reaches its backends by their
|
||||
# underscore aliases on archy-net (DB_HOSTNAME / REDIS_HOSTNAME below).
|
||||
container_name: immich_server
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/immich-server:release
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: DB_PASSWORD
|
||||
secret_file: immich-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: immich-postgres
|
||||
- app_id: immich-redis
|
||||
- storage: 200Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 200Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 2283
|
||||
container: 2283
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/immich
|
||||
target: /usr/src/app/upload
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- DB_HOSTNAME=immich_postgres
|
||||
- DB_USERNAME=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- REDIS_HOSTNAME=immich_redis
|
||||
- UPLOAD_LOCATION=/usr/src/app/upload
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:2283
|
||||
path: /api/server/ping
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Immich photo library
|
||||
type: ui
|
||||
port: 2283
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
@@ -0,0 +1,77 @@
|
||||
app:
|
||||
id: indeedhub-api
|
||||
name: IndeedHub API
|
||||
version: "1.0.0"
|
||||
description: IndeedHub backend API (Nostr auth, media, payments).
|
||||
category: community
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption);
|
||||
# alias `api` is the short hostname the frontend nginx proxies to
|
||||
# (http://api:4000). Reaches its backends by their short aliases
|
||||
# (postgres/redis/minio) on indeedhub-net — unchanged from the legacy installer.
|
||||
container_name: indeedhub-api
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub-api:1.0.0
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [api]
|
||||
# The JWT signing secret is owned here (no backend container owns it); the
|
||||
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
|
||||
# only consumed here. ensure_generated_secrets no-ops when a file already
|
||||
# exists, so live values on .228 are preserved (postgres pw is fixed at
|
||||
# PGDATA init — regenerating would lock the API out).
|
||||
generated_secrets:
|
||||
- name: indeedhub-jwt
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: indeedhub-db-password
|
||||
- key: AWS_SECRET_KEY
|
||||
secret_file: indeedhub-minio-password
|
||||
- key: NOSTR_JWT_SECRET
|
||||
secret_file: indeedhub-jwt
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-postgres
|
||||
- app_id: indeedhub-redis
|
||||
- app_id: indeedhub-minio
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment:
|
||||
- PORT=4000
|
||||
- DATABASE_HOST=postgres
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_USER=indeedhub
|
||||
- DATABASE_NAME=indeedhub
|
||||
- QUEUE_HOST=redis
|
||||
- QUEUE_PORT=6379
|
||||
- S3_ENDPOINT=http://minio:9000
|
||||
- AWS_REGION=us-east-1
|
||||
- AWS_ACCESS_KEY=indeeadmin
|
||||
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- S3_PUBLIC_BUCKET_URL=/storage
|
||||
- NOSTR_JWT_EXPIRES_IN=7d
|
||||
# Fixed across the fleet (envelope-encryption master key baked by the legacy
|
||||
# installer); not node-specific, so a plain env literal, not a secret.
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
- ENVIRONMENT=production
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:4000
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -0,0 +1,51 @@
|
||||
app:
|
||||
id: indeedhub-ffmpeg
|
||||
name: IndeedHub FFmpeg Worker
|
||||
version: "1.0.0"
|
||||
description: IndeedHub background media transcoding worker.
|
||||
category: community
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption). No
|
||||
# network_alias: nothing connects TO the worker — it only dials out to
|
||||
# postgres/redis/minio (resolved by their aliases on indeedhub-net).
|
||||
container_name: indeedhub-ffmpeg
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub-ffmpeg:1.0.0
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
secret_env:
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: indeedhub-db-password
|
||||
- key: AWS_SECRET_KEY
|
||||
secret_file: indeedhub-minio-password
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-api
|
||||
|
||||
resources:
|
||||
memory_limit: 4Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment:
|
||||
- DATABASE_HOST=postgres
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_USER=indeedhub
|
||||
- DATABASE_NAME=indeedhub
|
||||
- QUEUE_HOST=redis
|
||||
- QUEUE_PORT=6379
|
||||
- S3_ENDPOINT=http://minio:9000
|
||||
- AWS_REGION=us-east-1
|
||||
- AWS_ACCESS_KEY=indeeadmin
|
||||
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- ENVIRONMENT=production
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
@@ -0,0 +1,60 @@
|
||||
app:
|
||||
id: indeedhub-minio
|
||||
name: IndeedHub MinIO
|
||||
version: "RELEASE.2024-11-07T00-52-20Z"
|
||||
description: MinIO S3-compatible object storage for IndeedHub media.
|
||||
category: community
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption);
|
||||
# alias `minio` is the short hostname the api/ffmpeg use (S3_ENDPOINT=
|
||||
# http://minio:9000) AND the frontend nginx proxies to (http://minio:9000).
|
||||
container_name: indeedhub-minio
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [minio]
|
||||
# `server /data` — the minio entrypoint args from the legacy installer.
|
||||
custom_args: [server, /data]
|
||||
generated_secrets:
|
||||
- name: indeedhub-minio-password
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: MINIO_ROOT_PASSWORD
|
||||
secret_file: indeedhub-minio-password
|
||||
|
||||
dependencies:
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
# Named volume matches the live indeedhub-minio-data volume on .228.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: indeedhub-minio-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
# MINIO_ROOT_USER "indeeadmin" is the fixed admin identity baked by the legacy
|
||||
# installer (api/ffmpeg use it as AWS_ACCESS_KEY); the password is the
|
||||
# generated secret above. Not secret, so it stays a plain env value.
|
||||
environment:
|
||||
- MINIO_ROOT_USER=indeeadmin
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:9000
|
||||
path: /minio/health/live
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -0,0 +1,59 @@
|
||||
app:
|
||||
id: indeedhub-postgres
|
||||
name: IndeedHub Postgres
|
||||
version: "16.13-alpine"
|
||||
description: Postgres database backend for IndeedHub.
|
||||
category: community
|
||||
|
||||
# Container named indeedhub-postgres (hyphen) to match the runtime's existing
|
||||
# per-app references (health_monitor tiers/deps, crash_recovery) and the live
|
||||
# .228 install, so the orchestrator ADOPTS the running container instead of
|
||||
# recreating it. `network_aliases: [postgres]` keeps the short hostname the
|
||||
# api/ffmpeg/relay reach by (DATABASE_HOST=postgres) resolvable on
|
||||
# indeedhub-net, reproducing the legacy `--network-alias postgres`.
|
||||
container_name: indeedhub-postgres
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/postgres:16.13-alpine
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [postgres]
|
||||
generated_secrets:
|
||||
- name: indeedhub-db-password
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: POSTGRES_PASSWORD
|
||||
secret_file: indeedhub-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
# Named podman volume (matches the live indeedhub-postgres-data volume on .228);
|
||||
# preserves all existing database content across the migration.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: indeedhub-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- POSTGRES_USER=indeedhub
|
||||
- POSTGRES_DB=indeedhub
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:5432
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,45 @@
|
||||
app:
|
||||
id: indeedhub-redis
|
||||
name: IndeedHub Redis
|
||||
version: "7.4.8-alpine"
|
||||
description: Redis queue/cache backend for IndeedHub.
|
||||
category: community
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption);
|
||||
# alias `redis` is the short hostname the api/ffmpeg reach (QUEUE_HOST=redis).
|
||||
container_name: indeedhub-redis
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/redis:7.4.8-alpine
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [redis]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
capabilities: [SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
# Named volume matches the live indeedhub-redis-data volume on .228.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: indeedhub-redis-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:6379
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,47 @@
|
||||
app:
|
||||
id: indeedhub-relay
|
||||
name: IndeedHub Nostr Relay
|
||||
version: "0.9.0"
|
||||
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
|
||||
category: community
|
||||
|
||||
# Hyphen name matches runtime references + the live container (adoption);
|
||||
# alias `relay` is the short hostname the frontend nginx proxies to
|
||||
# (http://relay:8080 for the /relay websocket).
|
||||
container_name: indeedhub-relay
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [relay]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
# Named volume matches the live indeedhub-relay-data volume on .228.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: indeedhub-relay-data
|
||||
target: /usr/src/app/db
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8080
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
+49
-28
@@ -1,63 +1,84 @@
|
||||
app:
|
||||
id: indeedhub
|
||||
name: IndeeHub
|
||||
version: 1.0.0
|
||||
version: "1.0.0"
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||
category: community
|
||||
|
||||
# The user-facing launcher (app_id "indeedhub"). Container is named "indeedhub"
|
||||
# (matches the runtime's per-app references + the live container, so the
|
||||
# orchestrator adopts it). Its nginx (listen 7777) proxies to the backends by
|
||||
# their short aliases on indeedhub-net: api:4000, minio:9000, relay:8080.
|
||||
container_name: indeedhub
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-api
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1001
|
||||
seccomp_profile: default
|
||||
network_policy: bridge
|
||||
apparmor_profile: default
|
||||
# nginx master runs as root and drops workers to the nginx user (uid/gid
|
||||
# 101) — needs SET{UID,GID}; CHOWN + DAC_OVERRIDE let it own + write the
|
||||
# proxy cache under the tmpfs /var/cache/nginx. The orchestrator does
|
||||
# --cap-drop=ALL, so (unlike the legacy `podman run` default caps) these
|
||||
# must be declared or nginx workers die with "setgid(101) failed".
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
|
||||
|
||||
# Writable scratch the baked nginx needs; matches the legacy installer's
|
||||
# --tmpfs /run + /var/cache/nginx.
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
options: [rw,noexec,nosuid,size=64m]
|
||||
- type: tmpfs
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
- type: tmpfs
|
||||
target: /run
|
||||
options: [rw,nosuid,nodev,size=16m]
|
||||
options: [rw, nosuid, nodev, size=16m]
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,nosuid,nodev,size=32m]
|
||||
options: [rw, nosuid, nodev, size=32m]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
environment: []
|
||||
|
||||
# Defensive + idempotent. The current indeedhub:1.0.0 image already bakes the
|
||||
# iframe-friendly nginx (X-Frame-Options omitted, nostr-provider.js present +
|
||||
# <script> injected), so these are mostly no-ops on that tag — but they keep
|
||||
# the app iframe-loadable + the provider script fresh for any image build that
|
||||
# predates the bake. copy_from_host pulls /opt/archipelago/web-ui/nostr-provider.js
|
||||
# (kept current by frontend OTA releases). Replaces the legacy hardcoded
|
||||
# patch_indeedhub_nostr_provider() Rust hook.
|
||||
hooks:
|
||||
post_install:
|
||||
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["nginx", "-s", "reload"]
|
||||
|
||||
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||
# startup (before workers), so this passes immediately and stays green under
|
||||
# load. An http check of / runs the SPA + sub_filter and false-fails when the
|
||||
# node is busy → the reconciler then treats the frontend as wedged and
|
||||
# recreates it in a loop (observed churning the frontend on the loaded .198).
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3000
|
||||
path: /
|
||||
type: tcp
|
||||
endpoint: localhost:7777
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
|
||||
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -146,7 +146,9 @@ impl ApiHandler {
|
||||
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
|
||||
hyper::Body::from(
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -222,8 +224,12 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||
)),
|
||||
Err(e) => {
|
||||
// Surface the FULL error chain ({:#}) — the generic top-level
|
||||
// message hid the real cause (e.g. the LND REST connection
|
||||
// failing), which made this 503 undiagnosable.
|
||||
tracing::warn!("content invoice creation failed: {e:#}");
|
||||
let body = serde_json::json!({
|
||||
"error": format!("Could not create invoice: {e}")
|
||||
"error": format!("Could not create invoice: {e:#}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -171,6 +171,13 @@ impl RpcHandler {
|
||||
// than the WebSocket-delivered package_data, which caused apps to flicker
|
||||
// between "installed" and "not-installed" in the UI.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
// Apps the user explicitly stopped must read as "stopped" even though a
|
||||
// UI companion (electrs-ui, bitcoin-ui, …) keeps serving the launch port:
|
||||
// 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;
|
||||
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 {
|
||||
@@ -202,7 +209,11 @@ impl RpcHandler {
|
||||
// Scanner backoff preserves cached package_data. Refresh stable
|
||||
// states so callers do not see stale `running`/`exited` after
|
||||
// health-monitor recovery or Quadlet --rm container removal.
|
||||
if state == "running" && requires_launch_port_for_health(id) {
|
||||
if user_stopped.contains(id) {
|
||||
// User stopped it → authoritative "stopped". Do NOT let a
|
||||
// still-running UI companion's launch port mark it running.
|
||||
state = "stopped".to_string();
|
||||
} else if state == "running" && requires_launch_port_for_health(id) {
|
||||
if !self.cached_reachable_health(id).await?.is_some() {
|
||||
state = live_state_for_app(id)
|
||||
.await
|
||||
|
||||
@@ -19,6 +19,29 @@ fn is_valid_v3_onion(addr: &str) -> bool {
|
||||
|
||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
.map(|(sats, _fed)| sats),
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List content I'm sharing.
|
||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||
@@ -260,6 +283,20 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
|
||||
// the host's federation peers only — federate first"). Surface that to
|
||||
// the user instead of a bare "Peer returned: 403 Forbidden".
|
||||
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("Peer returned: {status}"));
|
||||
return Err(anyhow::anyhow!(msg));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
}
|
||||
@@ -369,10 +406,58 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// Mint ecash payment token
|
||||
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
||||
.await
|
||||
.context("Failed to create ecash payment token — check wallet balance")?;
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||
// verify_payment_token accepts either, so a node whose balance lives in
|
||||
// one system can still pay (#3).
|
||||
let method = params.get("method").and_then(|v| v.as_str());
|
||||
|
||||
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
|
||||
let mint_fedimint =
|
||||
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
|
||||
|
||||
let (token_str, used_backend) = match method {
|
||||
Some("cashu") => match mint_cashu().await {
|
||||
Ok(t) => (t, "cashu"),
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}");
|
||||
return Ok(serde_json::json!({ "error": format!(
|
||||
"Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \
|
||||
Fund it, or choose Fedimint."
|
||||
) }));
|
||||
}
|
||||
},
|
||||
Some("fedimint") => match mint_fedimint().await {
|
||||
Ok((notes, 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:#}");
|
||||
return Ok(serde_json::json!({ "error": format!(
|
||||
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
||||
Fund it, or choose Cashu."
|
||||
) }));
|
||||
}
|
||||
},
|
||||
_ => match mint_cashu().await {
|
||||
Ok(t) => (t, "cashu"),
|
||||
Err(cashu_err) => match mint_fedimint().await {
|
||||
Ok((notes, _fed)) => (notes, "fedimint"),
|
||||
Err(fedi_err) => {
|
||||
tracing::warn!(
|
||||
"paid download: no ecash backend could pay {price_sats} sats \
|
||||
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
||||
);
|
||||
return Ok(serde_json::json!({ "error": format!(
|
||||
"Couldn't pay {price_sats} sats from your ecash wallet \
|
||||
(Cashu or Fedimint). Fund either wallet and try again."
|
||||
) }));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
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)?;
|
||||
@@ -389,7 +474,7 @@ impl RpcHandler {
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
@@ -397,8 +482,11 @@ impl RpcHandler {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -412,30 +500,92 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
}));
|
||||
}
|
||||
|
||||
// Capture the content type BEFORE consuming the body so the local cache
|
||||
// can render the right viewer (image vs video) later.
|
||||
let mime_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||
// it unblurred and views it in-app from this cache — no re-payment and no
|
||||
// reliance on a browser download (which silently fails on the mobile
|
||||
// companion, the original "paid but never unlocked" report). Best-effort:
|
||||
// a cache-write failure must not fail an already-paid download.
|
||||
let filename = params
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(content_id)
|
||||
.to_string();
|
||||
let purchased_at = chrono::Utc::now().to_rfc3339();
|
||||
if let Err(e) = crate::content_owned::record_purchase(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
&filename,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
price_sats,
|
||||
used_backend,
|
||||
&purchased_at,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -463,12 +613,16 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
|
||||
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
|
||||
// give Tor a short-but-real window (onion circuits need a few seconds).
|
||||
let path = format!("/content/{}/invoice", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -524,11 +678,15 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
|
||||
// FIPS and keep a short Tor window; an unreachable peer just reads as
|
||||
// "not yet paid" and the UI polls again.
|
||||
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -652,12 +810,15 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Issuing an address is a tiny request/response — fast-fail FIPS, short
|
||||
// Tor window (same budget shape as the invoice path, #6).
|
||||
let path = format!("/content/{}/onchain", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -715,7 +876,8 @@ impl RpcHandler {
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -895,4 +1057,43 @@ impl RpcHandler {
|
||||
"preview_mode": is_preview,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `content.owned-list` — every paid item this node has purchased, so the
|
||||
/// gallery can render owned items unblurred/viewable without re-payment.
|
||||
pub(super) async fn handle_content_owned_list(&self) -> Result<serde_json::Value> {
|
||||
let items = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({ "items": items }))
|
||||
}
|
||||
|
||||
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
||||
/// local cache for in-app viewing/saving. No network, no re-payment.
|
||||
pub(super) async fn handle_content_owned_get(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
|
||||
match crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await {
|
||||
Some((mime_type, bytes)) => {
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"mime_type": mime_type,
|
||||
}))
|
||||
}
|
||||
None => Ok(serde_json::json!({
|
||||
"error": "You don't own this item yet, or its cached copy is missing."
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,8 @@ impl RpcHandler {
|
||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||
"content.owned-list" => self.handle_content_owned_list().await,
|
||||
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||
"content.download-peer-invoice" => {
|
||||
|
||||
@@ -156,6 +156,35 @@ impl RpcHandler {
|
||||
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
||||
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
||||
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
||||
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
|
||||
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
|
||||
/// backend is still in initial block download — otherwise the `/v1/invoices`
|
||||
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
|
||||
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
|
||||
/// `Some(false)` only when LND is reachable AND explicitly not synced;
|
||||
/// `None` when we couldn't tell (let the mint attempt proceed and report its
|
||||
/// own error rather than guess "syncing").
|
||||
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("synced_to_chain").and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// Error returned when the node can't mint a Lightning invoice because its
|
||||
/// Bitcoin backend is still syncing. Kept as one string so every invoice
|
||||
/// entry point surfaces the same clear, user-facing reason.
|
||||
fn syncing_invoice_err() -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
@@ -173,13 +202,55 @@ impl RpcHandler {
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
// LND's REST endpoint can briefly drop/reset connections under load
|
||||
// (swap pressure, just-restarted, TLS handshake races), which used to
|
||||
// hard-fail the buy-file invoice with an opaque 503. Retry on a
|
||||
// CONNECTION error with short backoff so a transient blip doesn't
|
||||
// surface as a payment failure. A *timeout* is NOT retried: it means LND
|
||||
// accepted the connection but isn't answering the mint (e.g. a degraded
|
||||
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
|
||||
// after the first hang and let the caller surface the real reason.
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let mut resp = None;
|
||||
for attempt in 0..3u32 {
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
resp = Some(r);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let timed_out = e.is_timeout();
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"LND REST send failed (attempt {}): {e}",
|
||||
attempt + 1
|
||||
));
|
||||
if timed_out {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = match resp {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// If LND is reachable but explicitly not synced to chain, say so —
|
||||
// it's the most common reason a just-restored/syncing node can't
|
||||
// mint. Otherwise surface the underlying transport error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("Failed to reach LND REST to create invoice")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
@@ -356,13 +427,23 @@ impl RpcHandler {
|
||||
"memo": memo,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// A hung/failed mint while LND is explicitly not synced to chain
|
||||
// gets a clear, user-facing reason instead of an opaque error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
|
||||
@@ -14,12 +14,12 @@ impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let cfg = {
|
||||
let (cfg, denied_askers) = {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
svc.assistant_config().await
|
||||
(svc.assistant_config().await, svc.assistant_denied_askers().await)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
@@ -32,10 +32,12 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
"models": models,
|
||||
"denied_askers": denied_askers,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -64,8 +66,18 @@ impl RpcHandler {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// allowed_contacts: present + array => replace the allowlist (pubkey hex
|
||||
// strings); absent => leave unchanged.
|
||||
let allowed_contacts = params
|
||||
.get("allowed_contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|e| e.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend)
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
@@ -73,6 +85,7 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -95,12 +95,17 @@ impl RpcHandler {
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
let messages = svc.messages(None).await;
|
||||
// Per-peer last message.
|
||||
for peer in &peers {
|
||||
// Collapse radio/federation twins into one conversation per identity
|
||||
// so a node reachable both ways shows once, with its messages unioned
|
||||
// across both twin contact_ids (#12).
|
||||
let groups = mesh::group_peer_twins(&peers);
|
||||
for group in &groups {
|
||||
let peer = &group.canonical;
|
||||
// Newest message across ALL twin contact_ids in this group.
|
||||
let last = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.peer_contact_id == peer.contact_id);
|
||||
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
|
||||
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||
conversations.push(serde_json::json!({
|
||||
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||
@@ -163,8 +168,16 @@ impl RpcHandler {
|
||||
let filtered: Vec<_> = match kind {
|
||||
"mesh" | "federation" => {
|
||||
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||
// Resolve this id's twin group and union messages across all of
|
||||
// its contact_ids, so opening either twin shows the full thread
|
||||
// (federation-injected + radio messages) (#12).
|
||||
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
|
||||
.into_iter()
|
||||
.find(|g| g.contact_ids.contains(&contact_id))
|
||||
.map(|g| g.contact_ids)
|
||||
.unwrap_or_else(|| vec![contact_id]);
|
||||
all.into_iter()
|
||||
.filter(|m| m.peer_contact_id == contact_id)
|
||||
.filter(|m| ids.contains(&m.peer_contact_id))
|
||||
.collect()
|
||||
}
|
||||
"channel" => {
|
||||
@@ -258,43 +271,45 @@ impl RpcHandler {
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let state = svc.state();
|
||||
|
||||
// Snapshot the firmware pubkeys we currently know about, then
|
||||
// add them to the radio-contact blocklist. MeshCore's on-device
|
||||
// contact table is persistent and reads back stale rows on the
|
||||
// next refresh_contacts, so without this step `clear-all` only
|
||||
// wipes the app view for a few seconds before the old entries
|
||||
// reappear. The blocklist is also saved to disk so the filter
|
||||
// survives a restart.
|
||||
let firmware_pubkeys: Vec<String> = state
|
||||
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||
// peer could never return even when it re-advertised (it also broke
|
||||
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||
// be a separate, explicit feature. Here we just wipe the app-side
|
||||
// view and ALSO clear any blocklist left over from older builds, so
|
||||
// previously-hidden contacts can re-appear when next heard. The
|
||||
// firmware's own contact table is the source of truth on refresh.
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
set.clear();
|
||||
}
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||
|
||||
// Actually DELETE each radio contact from the firmware table (via
|
||||
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||
// refresh. They come back only when they re-advertise (reachable).
|
||||
// Federation-synthetic peers (high contact_id bit) aren't firmware
|
||||
// contacts, so skip those.
|
||||
let firmware_pubkeys: Vec<[u8; 32]> = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
// Federation-synthetic peers have their contact_id in the
|
||||
// high half of u32 and carry the archipelago key — those
|
||||
// aren't firmware contacts and must not go on the list.
|
||||
if p.contact_id & 0x8000_0000 != 0 {
|
||||
None
|
||||
} else {
|
||||
p.pubkey_hex.clone()
|
||||
}
|
||||
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.filter(|b| b.len() == 32)
|
||||
.map(|b| {
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(&b);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
for pk in &firmware_pubkeys {
|
||||
set.insert(pk.clone());
|
||||
}
|
||||
for pk in firmware_pubkeys {
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||
.await;
|
||||
}
|
||||
let persisted: Vec<String> = state
|
||||
.radio_contact_blocklist
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
|
||||
|
||||
state.peers.write().await.clear();
|
||||
state.messages.write().await.clear();
|
||||
|
||||
@@ -1133,9 +1133,13 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let contacts = state.contacts.read().await;
|
||||
let peers = state.peers.read().await;
|
||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||
// Collapse radio/federation twins so a node reachable both ways shows as
|
||||
// one contact instead of two (#12).
|
||||
let groups = crate::mesh::group_peer_twins(&peer_vec);
|
||||
let mut out: Vec<serde_json::Value> = Vec::new();
|
||||
for peer in peers.values() {
|
||||
for group in &groups {
|
||||
let peer = &group.canonical;
|
||||
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
||||
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
||||
out.push(serde_json::json!({
|
||||
|
||||
@@ -349,13 +349,37 @@ fn http_probe_cmd(url: &'static str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitcoin UTXO cache (`-dbcache`) in MB, sized to host RAM.
|
||||
///
|
||||
/// A fixed large dbcache on a small box pushes bitcoind + the ~20 app
|
||||
/// containers past physical RAM and triggers system-wide swap thrash: the
|
||||
/// disk saturates, bitcoind can't answer its own RPC, and the dashboard
|
||||
/// backend's sqlite reads stall — surfacing as /rpc/v1 502s and a blank
|
||||
/// Bitcoin UI. Budget ~1/16 of RAM for the cache (floor 300 MB — bitcoind's
|
||||
/// own default is 450 — cap 4096 MB), mirroring scripts/container-specs.sh.
|
||||
pub(super) fn bitcoin_dbcache_mb() -> u64 {
|
||||
let total_mb = std::fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
c.lines()
|
||||
.find_map(|l| l.strip_prefix("MemTotal:"))
|
||||
.and_then(|v| v.split_whitespace().next())
|
||||
.and_then(|kb| kb.parse::<u64>().ok())
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(16000); // assume a comfortable host if /proc/meminfo is unreadable
|
||||
(total_mb / 16).clamp(300, 4096)
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
// Heavy apps. Bitcoin: dbcache is now host-RAM-aware (see
|
||||
// bitcoin_dbcache_mb), so the daemon's footprint scales with the box.
|
||||
// This cgroup cap is an upper bound for mempool + connection buffers +
|
||||
// script-verifier memory + I/O; a tight cap (4g) previously caused
|
||||
// OOM-cascades during IBD, so keep 8g as a generous ceiling rather
|
||||
// than a tight limit — swap thrash is prevented at the dbcache layer.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
@@ -674,9 +698,10 @@ pub(super) async fn get_app_config(
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -dbcache — UTXO set cache, sized to host RAM via
|
||||
// bitcoin_dbcache_mb() (see there). A fixed 4GB cache swap-
|
||||
// thrashed small nodes into fleet-wide 502s; ~1/16 of RAM
|
||||
// keeps headroom for mempool + connections + the app stack.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
@@ -689,7 +714,7 @@ pub(super) async fn get_app_config(
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
|
||||
@@ -376,16 +376,31 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
Ok(order_present_containers(package_id, containers))
|
||||
}
|
||||
|
||||
/// Order the *actually-present* containers of an app by its dependency-aware
|
||||
/// startup order. Containers whose name is unknown to the order list sort to
|
||||
/// the end, preserving their relative input order.
|
||||
///
|
||||
/// This deliberately does NOT inject order entries that aren't live
|
||||
/// containers. `startup_order` is a union of container-name variants across
|
||||
/// install generations (e.g. `mysql-mempool` vs `archy-mempool-db`), so any
|
||||
/// single install only ever has a subset of those names. Injecting a phantom
|
||||
/// name makes the start path fail on a "no such object" inspect — and because
|
||||
/// `do_orchestrator_package_start` propagates the unknown-app-id fallback
|
||||
/// 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).
|
||||
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.
|
||||
return vec![package_id.to_string()];
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
if order.is_empty() && containers.is_empty() {
|
||||
return Ok(vec![package_id.to_string()]);
|
||||
}
|
||||
let mut sorted = containers;
|
||||
for required in order {
|
||||
if !sorted.iter().any(|name| name == required) {
|
||||
sorted.push((*required).to_string());
|
||||
}
|
||||
}
|
||||
// If no special order is defined, fall back to mempool order for legacy
|
||||
// multi-container names that may still be returned by config lookups.
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
@@ -393,8 +408,14 @@ pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec
|
||||
} else {
|
||||
order
|
||||
};
|
||||
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
|
||||
Ok(sorted)
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| {
|
||||
effective_order
|
||||
.iter()
|
||||
.position(|o| *o == c)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
sorted
|
||||
}
|
||||
|
||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||
@@ -452,7 +473,48 @@ pub(super) fn configure_fedimint_lnd(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{requires_unpruned_bitcoin, startup_order};
|
||||
use super::{order_present_containers, requires_unpruned_bitcoin, startup_order};
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_never_injects_phantom_stack_members() {
|
||||
// The live mempool stack on a node: db + api + frontend. These are the
|
||||
// only real container names; the startup_order list also contains
|
||||
// variant/legacy names (mysql-mempool, archy-mempool-api, ...) that are
|
||||
// NOT live here and must never appear in the result — a phantom name in
|
||||
// the start list aborts the orchestrator start mid-sequence (gate
|
||||
// #73/#74).
|
||||
let present = vec![
|
||||
"mempool".to_string(),
|
||||
"mempool-api".to_string(),
|
||||
"archy-mempool-db".to_string(),
|
||||
];
|
||||
let ordered = order_present_containers("mempool", present);
|
||||
// Dependency order: db -> api -> frontend.
|
||||
assert_eq!(ordered, vec!["archy-mempool-db", "mempool-api", "mempool"]);
|
||||
// No phantom variants leaked in.
|
||||
for phantom in ["mysql-mempool", "archy-mempool-api", "archy-mempool-web"] {
|
||||
assert!(
|
||||
!ordered.iter().any(|c| c == phantom),
|
||||
"phantom {phantom} must not be injected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_orders_known_before_unknown() {
|
||||
let present = vec!["mempool".to_string(), "some-sidecar".to_string()];
|
||||
let ordered = order_present_containers("mempool", present);
|
||||
// The known frontend sorts ahead of an unknown sidecar.
|
||||
assert_eq!(ordered, vec!["mempool", "some-sidecar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_empty_falls_back_to_package_id() {
|
||||
assert_eq!(
|
||||
order_present_containers("mempool", vec![]),
|
||||
vec!["mempool".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn btcpay_start_order_includes_required_stack_members() {
|
||||
|
||||
@@ -22,6 +22,11 @@ const PODMAN_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Per-container graceful shutdown timeout in seconds.
|
||||
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
|
||||
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
|
||||
///
|
||||
/// MIRRORS `archipelago_container::runtime::stop_grace_secs_for` (which returns
|
||||
/// `u64` and is the canonical table used by the orchestrator stop path). This
|
||||
/// `&str` variant exists for the legacy `podman stop -t <s>` call sites here —
|
||||
/// keep the two tables in sync until those are migrated to the orchestrator.
|
||||
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
||||
let id = container_name
|
||||
.strip_prefix("archy-")
|
||||
|
||||
@@ -620,16 +620,25 @@ async fn install_stack_via_orchestrator(
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut installed = 0usize;
|
||||
for app_id in app_ids {
|
||||
match orchestrator.install(app_id).await {
|
||||
Ok(container_name) => {
|
||||
installed += 1;
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH: {} stack — app {} installed as {}",
|
||||
stack_name, app_id, container_name
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(e) if e.to_string().contains("unknown app_id") => {
|
||||
Err(e) if e.to_string().contains("unknown app_id") && installed == 0 => {
|
||||
// None of the stack's manifests are known — the orchestrator
|
||||
// can't render this stack at all, so defer to the legacy
|
||||
// installer. Only safe when NOTHING was installed yet: once an
|
||||
// earlier member is up, falling back would let the legacy path
|
||||
// double-create containers on the same data dir (observed
|
||||
// corrupting an immich postgres cluster — two postmasters, one
|
||||
// PGDATA). A partial set means a deploy bug, not a legacy node.
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH SKIP: {} stack — app {} unknown, falling back to legacy stack installer",
|
||||
stack_name, app_id
|
||||
@@ -637,6 +646,17 @@ async fn install_stack_via_orchestrator(
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) if e.to_string().contains("unknown app_id") => {
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH FAIL: {} stack — app {} unknown AFTER {} installed; refusing legacy fallback (would double-create on shared data)",
|
||||
stack_name, app_id, installed
|
||||
))
|
||||
.await;
|
||||
return Err(e.context(format!(
|
||||
"orchestrator stack install {} aborted: app {} has no manifest but {} member(s) already installed — deploy all stack manifests",
|
||||
stack_name, app_id, installed
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH FAIL: {} stack — app {} failed: {}",
|
||||
@@ -668,6 +688,31 @@ fn mempool_stack_app_ids() -> &'static [&'static str] {
|
||||
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||
}
|
||||
|
||||
fn immich_stack_app_ids() -> &'static [&'static str] {
|
||||
// Install order = dependency order: db + cache before the server. The server
|
||||
// app_id is the user-facing "immich" (canonical name + icon); its install is
|
||||
// handled here (not recursively) since orchestrator.install bypasses the
|
||||
// package.install routing that maps "immich" → this stack installer.
|
||||
&["immich-postgres", "immich-redis", "immich"]
|
||||
}
|
||||
|
||||
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),
|
||||
// then the ffmpeg worker, then the user-facing frontend ("indeedhub", which
|
||||
// carries the post_install nginx hook). The frontend's nginx reaches the
|
||||
// backends by their short network_aliases (api/minio/relay) on indeedhub-net.
|
||||
&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
]
|
||||
}
|
||||
|
||||
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0";
|
||||
@@ -734,6 +779,17 @@ async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
impl RpcHandler {
|
||||
/// Install Immich stack (postgres + redis + server).
|
||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||
// Manifest-driven path (workstream B/C): render the stack from
|
||||
// apps/immich-*/manifest.yml via the orchestrator (rootless Quadlet
|
||||
// units, generated_secrets, reboot-survivable). Falls back to the legacy
|
||||
// installer below only when the orchestrator doesn't know these app_ids
|
||||
// (manifests not yet deployed). See docs/PRODUCTION-MASTER-PLAN.md.
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "immich", immich_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"immich_server",
|
||||
"immich",
|
||||
@@ -1383,6 +1439,20 @@ impl RpcHandler {
|
||||
|
||||
/// Install the IndeedHub multi-container stack.
|
||||
pub(super) async fn install_indeedhub_stack(&self) -> Result<serde_json::Value> {
|
||||
// Manifest-driven path (#20 phase 3): render the 7-member stack from
|
||||
// apps/indeedhub-*/manifest.yml via the orchestrator (dedicated
|
||||
// indeedhub-net + network_aliases, generated_secrets, the frontend's
|
||||
// post_install nginx hook, reboot-survivable). The manifests use the exact
|
||||
// live container names / named volumes, so on an existing node this ADOPTS
|
||||
// the running stack rather than recreating it (data preserved). Falls back
|
||||
// to the legacy installer below only when the orchestrator doesn't know
|
||||
// these app_ids (manifests not yet deployed). See PRODUCTION-MASTER-PLAN.md.
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "indeedhub", indeedhub_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
let registry = crate::container::registry::load_registries(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@@ -1903,6 +1973,14 @@ impl RpcHandler {
|
||||
|
||||
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;
|
||||
@@ -1918,6 +1996,37 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, profits};
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
|
||||
/// string is routed to the Fedimint reissue path.
|
||||
fn is_cashu_token(token: &str) -> bool {
|
||||
token.trim_start().starts_with("cashu")
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let cashu_sats = wallet.balance();
|
||||
// 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,
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": wallet.balance(),
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
@@ -129,18 +151,42 @@ impl RpcHandler {
|
||||
let token = params
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
||||
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
|
||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||
// sidecar). Detect by prefix and route accordingly.
|
||||
if is_cashu_token(token) {
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "cashu",
|
||||
}));
|
||||
}
|
||||
|
||||
let (amount, federation_id) =
|
||||
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
|
||||
Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "fedimint",
|
||||
"federation_id": federation_id,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
|
||||
// Unified history: Cashu transactions (tagged kind="cashu") + the local
|
||||
// Fedimint transaction log (kind="fedimint"), newest first. Previously
|
||||
// only Cashu was returned, so a Fedimint receive showed up nowhere.
|
||||
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);
|
||||
// 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));
|
||||
Ok(serde_json::json!({
|
||||
"transactions": wallet.transactions,
|
||||
"transactions": transactions,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,32 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CACHE_REFRESH_SECS: u64 = 10;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
|
||||
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
|
||||
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
|
||||
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
|
||||
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
|
||||
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
|
||||
// timeout keeps last-known state fresh and clears the stale banner promptly.
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||
|
||||
// Grace window before a failing poll marks the snapshot "stale" for the UI.
|
||||
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
|
||||
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
|
||||
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
|
||||
// several polls failed in a row — do we surface the banner.
|
||||
const STALE_GRACE_MS: u64 = 20_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
// Server-computed age of the snapshot, filled in at serve time. The browser
|
||||
// must not derive this itself (Date.now() - updated_at_ms) because that
|
||||
// compares the browser clock against this node's clock — any skew made a
|
||||
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
|
||||
pub age_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
@@ -34,6 +52,7 @@ impl Default for BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
age_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
@@ -122,7 +141,11 @@ pub fn spawn_status_cache() {
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
cached.stale = true;
|
||||
// Only flip to "stale" once the last good snapshot is older
|
||||
// than the grace window. A brief RPC gap on a busy node keeps
|
||||
// showing last-known state silently instead of a banner flicker.
|
||||
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
|
||||
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
|
||||
cached.error = Some(friendly_transient_error(true, &err_msg));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
@@ -142,40 +165,46 @@ pub fn spawn_status_cache() {
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
cache().read().await.clone()
|
||||
let mut status = cache().read().await.clone();
|
||||
// Compute age here (server clock only) so the browser never has to subtract
|
||||
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
|
||||
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
|
||||
if status.updated_at_ms > 0 {
|
||||
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
|
||||
// but correctly; too tight a timeout turned working-but-slow polls into
|
||||
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getblockchaininfo")?;
|
||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getnetworkinfo")
|
||||
.ok();
|
||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
|
||||
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
|
||||
// the next refresh. Only getblockchaininfo failing marks the status stale.
|
||||
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
|
||||
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
|
||||
);
|
||||
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
age_ms: 0,
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info,
|
||||
index_info,
|
||||
zmq_notifications,
|
||||
network_info: network_info.ok(),
|
||||
index_info: index_info.ok(),
|
||||
zmq_notifications: zmq_notifications.ok(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ pub struct Config {
|
||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||
/// off so the legacy path stays the production path until the harness
|
||||
/// at tests/lifecycle/run-20x.sh has gone green against the new path
|
||||
/// at tests/lifecycle/run-gate.sh has gone green against the new path
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
@@ -487,7 +487,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_config_use_quadlet_backends_defaults_off() {
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 5×
|
||||
// harness goes green on .228 and .198. Flipping this default
|
||||
// ahead of that would route every backend install through code
|
||||
// we haven't fleet-validated yet.
|
||||
|
||||
@@ -86,6 +86,15 @@ pub struct AppCatalogEntry {
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
/// Full app manifest, embedded so the app installs from the registry alone —
|
||||
/// no OTA-shipped `apps/<id>/manifest.yml`. Carried as the raw value the
|
||||
/// publisher signed (so it stays part of the verified preimage) and
|
||||
/// deserialized into an `AppManifest` by the orchestrator at load time, where
|
||||
/// it overrides the disk manifest (origin-wins). Absent during the migration
|
||||
/// window => the node falls back to the disk manifest. See
|
||||
/// `docs/registry-manifest-design.md`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||
@@ -166,6 +175,18 @@ pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All `(app_id, manifest-value)` pairs the registry catalog carries. The
|
||||
/// orchestrator deserializes + validates each into an `AppManifest` and prefers
|
||||
/// it over the disk manifest (origin-wins); disk remains the migration fallback.
|
||||
/// Empty when the catalog is absent or no entry embeds a manifest.
|
||||
pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
load_catalog()
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Image override for the orchestrator's install/upgrade path. Returns the
|
||||
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
||||
/// repository as the manifest's current image — a guard so a catalog typo can
|
||||
@@ -346,6 +367,30 @@ mod tests {
|
||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_carries_embedded_manifest() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"apps": {
|
||||
"demo": {
|
||||
"version": "1.0.0",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"id": "demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"container": { "image": "registry/demo:1.0.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
let e = cat.apps.get("demo").unwrap();
|
||||
let m = e.manifest.as_ref().expect("manifest present");
|
||||
assert_eq!(m["app"]["id"], "demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_when_absent_is_default() {
|
||||
let cat = AppCatalog::default();
|
||||
|
||||
@@ -96,6 +96,35 @@ impl BootReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
// Companion self-heal runs on its OWN cadence, decoupled from the
|
||||
// per-app reconcile pass. On a heavily loaded node `reconcile_existing`
|
||||
// over dozens of apps can take well over a minute, which would delay a
|
||||
// companion-unit repair (deleted/lost unit file) past any reasonable
|
||||
// safety window. Detecting + rewriting a companion unit is cheap, so it
|
||||
// gets a dedicated `interval` loop. The handle is aborted when the main
|
||||
// loop exits (shutdown uses `notify_one`, so we must NOT add a second
|
||||
// waiter on `self.shutdown` — it would steal the single wake permit).
|
||||
let companion_handle = if self.companion_stage {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
Some(tokio::spawn(async move {
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||
{
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
time::sleep(interval).await;
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Initial pass: no delay.
|
||||
self.tick().await;
|
||||
|
||||
@@ -111,23 +140,15 @@ impl BootReconciler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(handle) = companion_handle {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
|
||||
if !self.companion_stage {
|
||||
return;
|
||||
}
|
||||
let installed = self.orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_report(report: &ReconcileReport) {
|
||||
|
||||
@@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
// Host networking so the app's own nginx can proxy the archipelago backend
|
||||
// same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
|
||||
// previous bridge + 18083→80 mapping forced the browser to fetch the
|
||||
// backend cross-origin from the app's port, which depended on the host
|
||||
// nginx route + a CORS Origin/Host match and broke on http-only nodes
|
||||
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
|
||||
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
@@ -214,13 +221,26 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
}
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
// exactly why the guardian-CSS nginx fix never reached the fleet.
|
||||
if image_exists(&local_image).await {
|
||||
return Ok(local_image);
|
||||
if !context_is_newer_than_image(dir, &local_image).await {
|
||||
return Ok(local_image);
|
||||
}
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"build context changed since image built; rebuilding {dir}"
|
||||
);
|
||||
} else {
|
||||
info!(companion = spec.name, "building locally from {dir}");
|
||||
}
|
||||
info!(companion = spec.name, "building locally from {dir}");
|
||||
let out = command_output_with_timeout(
|
||||
Command::new("podman").args(["build", "-t", &local_image, dir]),
|
||||
COMPANION_BUILD_TIMEOUT,
|
||||
@@ -279,6 +299,73 @@ async fn image_exists(image: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any file in the build context `dir` is newer than the
|
||||
/// already-built `image`, signalling the cached image is stale and must be
|
||||
/// rebuilt. Conservative: if either timestamp can't be determined we return
|
||||
/// false (reuse the cache) to avoid rebuild storms on every reconcile pass.
|
||||
async fn context_is_newer_than_image(dir: &str, image: &str) -> bool {
|
||||
let image_created = match image_created_unix(image).await {
|
||||
Some(t) => t,
|
||||
None => return false,
|
||||
};
|
||||
match newest_mtime_unix(PathBuf::from(dir)).await {
|
||||
Some(ctx) => ctx > image_created,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build timestamp of `image` as Unix seconds, via `podman image inspect`.
|
||||
async fn image_created_unix(image: &str) -> Option<i64> {
|
||||
let mut cmd = Command::new("podman");
|
||||
cmd.args(["image", "inspect", "--format", "{{.Created.Unix}}", image]);
|
||||
let out = command_output_with_timeout(
|
||||
&mut cmd,
|
||||
COMPANION_IMAGE_CHECK_TIMEOUT,
|
||||
"podman image created time",
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<i64>().ok()
|
||||
}
|
||||
|
||||
/// Newest modification time (Unix seconds) across all files under `dir`,
|
||||
/// walked recursively. Runs on a blocking thread since it touches the fs.
|
||||
async fn newest_mtime_unix(dir: PathBuf) -> Option<i64> {
|
||||
tokio::task::spawn_blocking(move || newest_mtime_blocking(&dir))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn newest_mtime_blocking(dir: &std::path::Path) -> Option<i64> {
|
||||
let mut newest: Option<i64> = None;
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(p) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&p) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(entry.path());
|
||||
} else if let Ok(modified) = meta.modified() {
|
||||
if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
|
||||
let secs = dur.as_secs() as i64;
|
||||
newest = Some(newest.map_or(secs, |n| n.max(secs)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
async fn command_output_with_timeout(
|
||||
cmd: &mut Command,
|
||||
timeout: Duration,
|
||||
@@ -439,12 +526,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
||||
fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
|
||||
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
|
||||
// app can proxy the archipelago backend same-origin instead of fetching
|
||||
// it cross-origin from its app port — see the spec comment for why.
|
||||
let spec = &LND_UI[0];
|
||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||
assert_eq!(u.name, "archy-lnd-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.ports.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"fedimint-clientd" | "fmcd" => AppMetadata {
|
||||
title: "Fedimint Client".to_string(),
|
||||
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
|
||||
icon: "/assets/img/app-icons/fedimint.png".to_string(),
|
||||
repo: "https://github.com/minmoto/fmcd".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
title: "Morphos".to_string(),
|
||||
description: "Self-hosted file converter".to_string(),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Manifest-driven lifecycle hook executor (Task #20).
|
||||
//!
|
||||
//! Runs an app's declarative `post_install` hooks against its **own** running
|
||||
//! container. Hooks are an allowlisted, reviewed escape hatch — NOT arbitrary
|
||||
//! host scripts:
|
||||
//!
|
||||
//! - `exec` runs *inside the container* (`podman exec`), never on the host, and
|
||||
//! inherits the container's (already dropped) capabilities.
|
||||
//! - `copy_from_host.src` is resolved against an allowlist root, canonicalised,
|
||||
//! and rejected on any escape; only then is it `podman cp`'d into the container.
|
||||
//! - Execution is **best-effort + idempotent**: each step is logged, a failure is
|
||||
//! warned and the remaining steps still run, so a transient hook error never
|
||||
//! bricks an install. Authors must make steps safe to re-run (e.g. `grep -q … ||`).
|
||||
//!
|
||||
//! See `docs/manifest-hooks-design.md`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use archipelago_container::{AppManifest, HookStep};
|
||||
|
||||
/// Upper bound on a single hook command. Generous — config rewrites + nginx
|
||||
/// reloads are fast, but an image with a hung entrypoint shouldn't wedge install.
|
||||
const HOOK_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Roots a `copy_from_host.src` may resolve within. A src is joined onto each
|
||||
/// root, canonicalised, and accepted only if it stays inside that root:
|
||||
/// - the app's own data dir (`<data_dir>/<app_id>`), and
|
||||
/// - `/opt/archipelago` (covers the orchestrator's bundled `web-ui/` assets,
|
||||
/// e.g. indeedhub's `web-ui/nostr-provider.js`).
|
||||
fn allowlist_roots(app_id: &str, data_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![data_dir.join(app_id), PathBuf::from("/opt/archipelago")]
|
||||
}
|
||||
|
||||
/// Resolve a hook copy source against the allowlist. Returns the canonical
|
||||
/// absolute path iff it exists and lies within an allowlist root. Defence in
|
||||
/// depth: `AppManifest::validate` already rejects absolute / `..` srcs, but we
|
||||
/// re-check here and canonicalise so a symlink inside a root can't escape it.
|
||||
fn resolve_copy_src(src: &str, app_id: &str, data_dir: &Path) -> Result<PathBuf> {
|
||||
if src.is_empty() || src.starts_with('/') || src.contains("..") {
|
||||
bail!("hook copy src '{src}' is not an allowlisted relative path");
|
||||
}
|
||||
for root in allowlist_roots(app_id, data_dir) {
|
||||
let Ok(root_canon) = root.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(canon) = root.join(src).canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if canon.starts_with(&root_canon) {
|
||||
return Ok(canon);
|
||||
}
|
||||
}
|
||||
bail!("hook copy src '{src}' did not resolve inside an allowlist root")
|
||||
}
|
||||
|
||||
/// Run an app's declarative `post_install` hooks against its running container.
|
||||
/// Best-effort: never returns an error — a failed step is warned and skipped.
|
||||
/// Called from the install path after the container is created + running, and
|
||||
/// only when a fresh container was created (see `install_fresh`).
|
||||
pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data_dir: &Path) {
|
||||
let steps = &manifest.app.hooks.post_install;
|
||||
if steps.is_empty() {
|
||||
return;
|
||||
}
|
||||
let app_id = &manifest.app.id;
|
||||
tracing::info!(
|
||||
app_id = %app_id,
|
||||
container = %container_name,
|
||||
steps = steps.len(),
|
||||
"running manifest post_install hooks"
|
||||
);
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
match run_step(step, container_name, app_id, data_dir).await {
|
||||
Ok(()) => tracing::debug!(app_id = %app_id, step = i, "post_install hook step ok"),
|
||||
Err(err) => tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %container_name,
|
||||
step = i,
|
||||
error = %err,
|
||||
"post_install hook step failed (continuing best-effort)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_step(
|
||||
step: &HookStep,
|
||||
container: &str,
|
||||
app_id: &str,
|
||||
data_dir: &Path,
|
||||
) -> Result<()> {
|
||||
match step {
|
||||
HookStep::Exec { exec } => {
|
||||
let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2);
|
||||
args.push("exec");
|
||||
args.push(container);
|
||||
args.extend(exec.iter().map(String::as_str));
|
||||
// `exec` spawns a process INSIDE the container's cgroup. When the
|
||||
// container was started by archipelago.service, that cgroup is under
|
||||
// the service's slice and a bare `podman exec` from the service can't
|
||||
// write its `cgroup.procs` ("crun: ... Permission denied / OCI
|
||||
// permission denied"). Run it in a transient user scope (its own
|
||||
// delegated cgroup) — mirrors `podman_user_scope` for pasta starts.
|
||||
run_podman(&args, /* scoped */ true).await
|
||||
}
|
||||
HookStep::CopyFromHost { copy_from_host } => {
|
||||
let abs = resolve_copy_src(©_from_host.src, app_id, data_dir)?;
|
||||
let abs = abs.to_string_lossy().into_owned();
|
||||
let dest = format!("{container}:{}", copy_from_host.dest);
|
||||
// `cp` is a host-side copy (no in-container process), so no scope needed.
|
||||
run_podman(&["cp", &abs, &dest], /* scoped */ false).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a podman command, optionally inside a transient systemd user scope. The
|
||||
/// scope gives the invocation its own delegated cgroup so `podman exec` can
|
||||
/// place its child process — without it, an exec launched from the service's
|
||||
/// own cgroup is denied write to the container's `cgroup.procs`.
|
||||
async fn run_podman(args: &[&str], scoped: bool) -> Result<()> {
|
||||
let rendered = args.join(" ");
|
||||
let mut cmd = if scoped {
|
||||
let mut c = tokio::process::Command::new("systemd-run");
|
||||
c.args(["--user", "--scope", "--quiet", "--collect", "podman"]);
|
||||
c.args(args);
|
||||
c
|
||||
} else {
|
||||
let mut c = tokio::process::Command::new("podman");
|
||||
c.args(args);
|
||||
c
|
||||
};
|
||||
let out = tokio::time::timeout(HOOK_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("podman {rendered} timed out after {:?}", HOOK_TIMEOUT))?
|
||||
.map_err(|e| anyhow::anyhow!("podman {rendered}: {e}"))?;
|
||||
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"podman {rendered} exited {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_copy_src_accepts_file_in_app_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let data_dir = tmp.path();
|
||||
let app_dir = data_dir.join("myapp/web-ui");
|
||||
std::fs::create_dir_all(&app_dir).unwrap();
|
||||
std::fs::write(app_dir.join("provider.js"), b"x").unwrap();
|
||||
|
||||
let got = resolve_copy_src("web-ui/provider.js", "myapp", data_dir).unwrap();
|
||||
assert!(got.ends_with("myapp/web-ui/provider.js"));
|
||||
assert!(got.is_absolute());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_copy_src_rejects_absolute() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(resolve_copy_src("/etc/passwd", "myapp", tmp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_copy_src_rejects_traversal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(resolve_copy_src("web-ui/../../etc/shadow", "myapp", tmp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_copy_src_rejects_missing_file() {
|
||||
// Inside the allowlist shape but the file doesn't exist → canonicalize fails.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("myapp")).unwrap();
|
||||
assert!(resolve_copy_src("nope.js", "myapp", tmp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_copy_src_rejects_symlink_escape() {
|
||||
// A symlink inside the app dir pointing outside it must be rejected by
|
||||
// the post-canonicalisation prefix check.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app_dir = tmp.path().join("myapp");
|
||||
std::fs::create_dir_all(&app_dir).unwrap();
|
||||
let secret = tmp.path().join("secret.txt");
|
||||
std::fs::write(&secret, b"s").unwrap();
|
||||
let link = app_dir.join("link.js");
|
||||
if std::os::unix::fs::symlink(&secret, &link).is_ok() {
|
||||
// `secret.txt` lives in the tmp root, NOT under <data_dir>/myapp, so
|
||||
// the canonical target escapes the app-data root. It also isn't under
|
||||
// /opt/archipelago. Must be rejected.
|
||||
assert!(resolve_copy_src("link.js", "myapp", tmp.path()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod hooks;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
pub mod secrets;
|
||||
pub mod traits;
|
||||
|
||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||
|
||||
@@ -50,15 +50,6 @@ use crate::update::host_sudo;
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
const INDEEDHUB_BACKEND_CONTAINERS: &[&str] = &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
];
|
||||
const INDEEDHUB_FRONTEND_READY_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
fn is_required_baseline_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
@@ -70,6 +61,10 @@ fn is_required_baseline_app(app_id: &str) -> bool {
|
||||
| "mempool"
|
||||
| "archy-mempool-db"
|
||||
| "filebrowser"
|
||||
// fmcd: bundled on every node so the wallet's Fedimint side works
|
||||
// out of the box (auto-joins the default federation). Self-heals if
|
||||
// removed, like the other baseline services.
|
||||
| "fedimint-clientd"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -176,6 +171,22 @@ pub fn compute_container_name(manifest: &AppManifest) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the graceful-stop grace (seconds) for an app: the manifest
|
||||
/// `stop_grace_secs` extension if declared (manifest-driven, north-star), else
|
||||
/// the historical per-app `stop_timeout_secs` table keyed by container name.
|
||||
pub fn resolve_stop_grace_secs(manifest: &AppManifest, container_name: &str) -> u64 {
|
||||
if let Some(v) = manifest.app.extensions.get("stop_grace_secs") {
|
||||
// Accept either a YAML integer or a numeric string.
|
||||
if let Some(n) = v.as_u64() {
|
||||
return n;
|
||||
}
|
||||
if let Some(n) = v.as_str().and_then(|s| s.trim().parse::<u64>().ok()) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
archipelago_container::runtime::stop_grace_secs_for(container_name)
|
||||
}
|
||||
|
||||
/// Fingerprint a local build context so a changed source tree (e.g. a rebuilt
|
||||
/// `neode-ui` dist copied into `docker/<ui>/`) forces an image rebuild even
|
||||
/// when the image tag already exists (#34). Walks the context directory and
|
||||
@@ -761,102 +772,6 @@ async fn restart_container_scoped_if_pasta(
|
||||
}
|
||||
}
|
||||
|
||||
async fn patch_indeedhub_nostr_provider() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"/X-Frame-Options/d",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"cp",
|
||||
provider_src,
|
||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"grep",
|
||||
"-q",
|
||||
"nostr-provider",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if !already_patched {
|
||||
let cat_out = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = cat_out {
|
||||
if out.status.success() {
|
||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
expires off;\n\
|
||||
}\n\n\
|
||||
location = /sw.js {",
|
||||
);
|
||||
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
conf
|
||||
};
|
||||
|
||||
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(tmp_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Outcome of `reconcile_all` for a single app.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReconcileAction {
|
||||
@@ -905,6 +820,39 @@ struct LoadedManifest {
|
||||
manifest_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Validate a catalog-carried manifest value for `app_id`, returning the
|
||||
/// `AppManifest` to overlay over the disk manifest, or `None` to keep the disk
|
||||
/// fallback. Returns `None` on: an unparseable value, an embedded app id that
|
||||
/// mismatches the catalog key, a manifest that fails `validate()`, or a build
|
||||
/// source (build contexts aren't registry-distributed yet — phase 1 is
|
||||
/// image-only). See `docs/registry-manifest-design.md`.
|
||||
fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option<AppManifest> {
|
||||
let m: AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
struct OrchestratorState {
|
||||
/// app_id → loaded manifest
|
||||
manifests: HashMap<String, LoadedManifest>,
|
||||
@@ -946,7 +894,7 @@ pub struct ProdContainerOrchestrator {
|
||||
/// Quadlet `.container` unit and starts it via systemctl --user
|
||||
/// instead of shelling out to `podman create + start`. Default
|
||||
/// false so the legacy path remains the production path until the
|
||||
/// 20× lifecycle harness goes green against the new path.
|
||||
/// 5× lifecycle harness goes green against the new path.
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
@@ -1135,7 +1083,35 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
state.manifests.insert(lm.manifest.app.id.clone(), lm);
|
||||
}
|
||||
Ok(count)
|
||||
|
||||
// Registry-distributed manifests (workstream B): the signed catalog may
|
||||
// carry full manifests. Overlay them over disk — the registry is the
|
||||
// authoritative origin; disk is the migration fallback. Image-only apps
|
||||
// (phase 1); build-source catalog manifests defer to disk.
|
||||
// See docs/registry-manifest-design.md.
|
||||
let _ = count; // disk count subsumed by the merged total below
|
||||
let mut overlaid = 0usize;
|
||||
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
if let Some(m) = catalog_manifest_to_overlay(&app_id, value) {
|
||||
// Reuse the disk dir when the app also exists on disk (so a future
|
||||
// build-source catalog manifest can still resolve its context);
|
||||
// otherwise a sentinel under the manifests dir. Image-only apps
|
||||
// never read manifest_dir.
|
||||
let manifest_dir = state
|
||||
.manifests
|
||||
.get(&app_id)
|
||||
.map(|lm| lm.manifest_dir.clone())
|
||||
.unwrap_or_else(|| root.join(&app_id));
|
||||
state
|
||||
.manifests
|
||||
.insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir });
|
||||
overlaid += 1;
|
||||
}
|
||||
}
|
||||
if overlaid > 0 {
|
||||
tracing::info!("registry catalog overlaid {overlaid} manifest(s) over disk");
|
||||
}
|
||||
Ok(state.manifests.len())
|
||||
}
|
||||
|
||||
/// Test helper: inject a manifest directly without touching the filesystem.
|
||||
@@ -1309,13 +1285,12 @@ impl ProdContainerOrchestrator {
|
||||
mode: ReconcileMode,
|
||||
) -> Result<ReconcileAction> {
|
||||
let app_id = lm.manifest.app.id.clone();
|
||||
if app_id == "indeedhub" {
|
||||
// IndeedHub is a multi-container stack installed by the package
|
||||
// stack path. Boot reconcile must not fresh-install the catalog
|
||||
// manifest, but it does need to start/repair an already-installed
|
||||
// stack and reapply the frontend's Nostr provider patch after boot.
|
||||
return self.reconcile_indeedhub_stack(mode).await;
|
||||
}
|
||||
// IndeedHub used to be a hardcoded orchestrator special-case
|
||||
// (reconcile_indeedhub_stack + a dependency-DNS gate) that refused to
|
||||
// create the frontend from its manifest. It is now fully manifest-driven
|
||||
// (apps/indeedhub-* + apps/indeedhub): network_aliases, generated_secrets,
|
||||
// dependencies, and the post_install nginx hook live in the manifests, so
|
||||
// every member — frontend included — flows through the generic path here.
|
||||
let lock = self.app_lock(&app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
@@ -1351,6 +1326,27 @@ impl ProdContainerOrchestrator {
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
|
||||
// An explicitly user-stopped app MUST stay stopped. The reconcile filter
|
||||
// already drops user-stopped apps, but its `dependency_required` override
|
||||
// re-includes a stopped app that an *active* app depends on (e.g. mempool
|
||||
// keeps electrumx in the list), and the in-memory `disabled` set is wiped
|
||||
// on manifest reload — so reconcile would resurrect it: its now-unreachable
|
||||
// ports look like a fault, the host-port "repair" restarts it, and
|
||||
// package.stop never sticks. Honour the on-disk marker here, the single
|
||||
// choke point every reconcile flows through. Explicit install/start/restart
|
||||
// clear the marker BEFORE calling this, so they are unaffected.
|
||||
{
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||
if user_stopped.contains(&app_id) || user_stopped.contains(&name) {
|
||||
tracing::debug!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"reconcile skipped — app is user-stopped (must stay stopped)"
|
||||
);
|
||||
return Ok(ReconcileAction::Left("user-stopped".into()));
|
||||
}
|
||||
}
|
||||
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => {
|
||||
// Phase 3.3: migrate pre-Phase-3 containers in place, but only
|
||||
@@ -1742,7 +1738,7 @@ impl ProdContainerOrchestrator {
|
||||
} else {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
ensure_user_podman_socket().await?;
|
||||
// Legacy path. Production until tests/lifecycle/run-20x.sh
|
||||
// Legacy path. Production until tests/lifecycle/run-gate.sh
|
||||
// goes green against the Quadlet path.
|
||||
self.runtime
|
||||
.create_container(&resolved_manifest, &name, 0)
|
||||
@@ -1753,6 +1749,11 @@ impl ProdContainerOrchestrator {
|
||||
.with_context(|| format!("start_container {name}"))?;
|
||||
}
|
||||
self.run_post_start_hooks(&lm.manifest.app.id).await?;
|
||||
// Declarative manifest post_install hooks (Task #20). Runs only here, on a
|
||||
// freshly created container — exactly when container mutations (e.g.
|
||||
// indeedhub's nginx X-Frame-Options strip + nostr-provider injection) must
|
||||
// be re-applied. Best-effort + idempotent: never fails the install.
|
||||
crate::container::hooks::run_post_install(&resolved_manifest, &name, &self.data_dir).await;
|
||||
if uses_pasta_network(&resolved_manifest) {
|
||||
if let Err(err) = wait_for_manifest_host_ports(
|
||||
&resolved_manifest,
|
||||
@@ -2251,10 +2252,6 @@ impl ProdContainerOrchestrator {
|
||||
self.ensure_btcpay_stack_dirs().await?;
|
||||
Ok(Some(HookOutcome::Unchanged))
|
||||
}
|
||||
"indeedhub" => {
|
||||
self.start_indeedhub_backends().await?;
|
||||
Ok(Some(HookOutcome::Unchanged))
|
||||
}
|
||||
"grafana" => {
|
||||
self.cleanup_stale_grafana_port().await;
|
||||
Ok(Some(HookOutcome::Unchanged))
|
||||
@@ -2328,272 +2325,6 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_indeedhub_backends(&self) -> Result<()> {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "indeedhub-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
for name in INDEEDHUB_BACKEND_CONTAINERS {
|
||||
let status = match self.runtime.get_container_status(name).await {
|
||||
Ok(status) => status,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !matches!(status.state, ContainerState::Running) {
|
||||
if let Err(err) = podman_user_scope(&["start", name]).await {
|
||||
tracing::warn!(
|
||||
container = %name,
|
||||
error = %err,
|
||||
"IndeedHub scoped backend start failed; falling back to runtime start"
|
||||
);
|
||||
self.runtime
|
||||
.start_container(name)
|
||||
.await
|
||||
.with_context(|| format!("start IndeedHub backend {name}"))?;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
self.repair_indeedhub_network_aliases().await;
|
||||
self.wait_for_indeedhub_dependencies_ready(120).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_indeedhub_stack(&self, mode: ReconcileMode) -> Result<ReconcileAction> {
|
||||
let frontend_status = match self.runtime.get_container_status("indeedhub").await {
|
||||
Ok(status) => status,
|
||||
Err(_) => {
|
||||
if mode == ReconcileMode::ExistingOnly {
|
||||
return Ok(ReconcileAction::Left("absent".to_string()));
|
||||
}
|
||||
// Fresh stack creation is owned by package::stacks so we do not
|
||||
// create a single broken frontend container from the manifest.
|
||||
return Ok(ReconcileAction::Left("stack-managed".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
self.start_indeedhub_backends().await?;
|
||||
|
||||
let mut started = false;
|
||||
match frontend_status.state {
|
||||
ContainerState::Running => {}
|
||||
ContainerState::Stopped
|
||||
| ContainerState::Exited
|
||||
| ContainerState::Created
|
||||
| ContainerState::Stopping => {
|
||||
if let Err(err) = podman_user_scope(&["start", "indeedhub"]).await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"IndeedHub scoped frontend start failed; falling back to runtime start"
|
||||
);
|
||||
self.runtime
|
||||
.start_container("indeedhub")
|
||||
.await
|
||||
.context("start IndeedHub frontend during reconcile")?;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
ContainerState::Paused => return Ok(ReconcileAction::Left("paused".to_string())),
|
||||
ContainerState::Unknown(s) => return Ok(ReconcileAction::Left(s)),
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
self.repair_indeedhub_network_aliases().await;
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
|
||||
let frontend_stable = wait_for_container_stable_running(
|
||||
self.runtime.as_ref(),
|
||||
"indeedhub",
|
||||
5,
|
||||
INDEEDHUB_FRONTEND_READY_TIMEOUT_SECS,
|
||||
)
|
||||
.await;
|
||||
if frontend_stable.is_err() || !wait_for_host_port(7778, 10).await {
|
||||
tracing::warn!(
|
||||
error = ?frontend_stable.err(),
|
||||
"IndeedHub frontend did not stay reachable after reconcile; restarting"
|
||||
);
|
||||
let _ = self.runtime.stop_container("indeedhub").await;
|
||||
if let Err(err) = podman_user_scope(&["start", "indeedhub"]).await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"IndeedHub scoped frontend restart failed; falling back to runtime start"
|
||||
);
|
||||
self.runtime
|
||||
.start_container("indeedhub")
|
||||
.await
|
||||
.context("restart IndeedHub frontend after failed readiness")?;
|
||||
}
|
||||
started = true;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
wait_for_container_stable_running(
|
||||
self.runtime.as_ref(),
|
||||
"indeedhub",
|
||||
5,
|
||||
INDEEDHUB_FRONTEND_READY_TIMEOUT_SECS,
|
||||
)
|
||||
.await
|
||||
.context("IndeedHub frontend did not remain running after restart")?;
|
||||
if !wait_for_host_port(7778, 30).await {
|
||||
return Err(anyhow::anyhow!(
|
||||
"IndeedHub frontend did not expose host port 7778 after restart"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if started {
|
||||
Ok(ReconcileAction::Started)
|
||||
} else {
|
||||
Ok(ReconcileAction::NoOp)
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_indeedhub_dependencies_ready(&self, timeout_secs: u64) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
let mut last = String::from("not checked");
|
||||
loop {
|
||||
let mut all_running = true;
|
||||
for name in INDEEDHUB_BACKEND_CONTAINERS {
|
||||
match self.runtime.get_container_status(name).await {
|
||||
Ok(status) if matches!(status.state, ContainerState::Running) => {}
|
||||
Ok(status) => {
|
||||
all_running = false;
|
||||
last = format!("{name} state {:?}", status.state);
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
all_running = false;
|
||||
last = format!("{name} status error: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_running && self.indeedhub_api_dependency_dns_ready().await {
|
||||
return Ok(());
|
||||
}
|
||||
if all_running {
|
||||
last = "indeedhub-api dependency DNS not ready".to_string();
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(anyhow::anyhow!(
|
||||
"IndeedHub dependencies were not ready within {}s ({})",
|
||||
timeout_secs,
|
||||
last
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn indeedhub_api_dependency_dns_ready(&self) -> bool {
|
||||
let aliases_ready = self.indeedhub_required_aliases_present().await;
|
||||
if cfg!(test) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for host in ["postgres", "redis", "minio", "relay"] {
|
||||
let Ok(Ok(output)) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub-api", "getent", "hosts", host])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return aliases_ready;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return aliases_ready;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn indeedhub_required_aliases_present(&self) -> bool {
|
||||
for (container, alias) in [
|
||||
("indeedhub-postgres", "postgres"),
|
||||
("indeedhub-redis", "redis"),
|
||||
("indeedhub-minio", "minio"),
|
||||
("indeedhub-relay", "relay"),
|
||||
("indeedhub-api", "api"),
|
||||
("indeedhub", "indeedhub"),
|
||||
] {
|
||||
if !self.indeedhub_alias_present(container, alias).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network_aliases(&self) {
|
||||
for (container, alias) in [
|
||||
("indeedhub-postgres", "postgres"),
|
||||
("indeedhub-redis", "redis"),
|
||||
("indeedhub-minio", "minio"),
|
||||
("indeedhub-relay", "relay"),
|
||||
("indeedhub-api", "api"),
|
||||
("indeedhub", "indeedhub"),
|
||||
] {
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", container])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
if self.indeedhub_alias_present(container, alias).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "disconnect", "-f", "indeedhub-net", container])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
alias,
|
||||
"indeedhub-net",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn indeedhub_alias_present(&self, container: &str, alias: &str) -> bool {
|
||||
let output = match tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
container,
|
||||
"--format",
|
||||
"{{json .NetworkSettings.Networks}}",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => output,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let Ok(networks) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
|
||||
return false;
|
||||
};
|
||||
networks
|
||||
.get("indeedhub-net")
|
||||
.and_then(|network| network.get("Aliases"))
|
||||
.and_then(|aliases| aliases.as_array())
|
||||
.map(|aliases| aliases.iter().any(|value| value.as_str() == Some(alias)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn cleanup_stale_grafana_port(&self) {
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", "pasta.*3001"])
|
||||
@@ -2728,10 +2459,19 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
.context("ensuring bitcoin tx-relay credentials")?;
|
||||
}
|
||||
// Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now
|
||||
// declared as `generated_secrets` in their manifests and materialised
|
||||
// generically in `resolve_dynamic_env` — no per-app code here.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
// Materialise any manifest-declared generated secrets before they're
|
||||
// read below. This is the single chokepoint every install/reconcile
|
||||
// path funnels through, so an app's secrets exist by the time its
|
||||
// `secret_env` resolves — no per-app code, no host provisioning.
|
||||
crate::container::secrets::ensure_generated_secrets(&self.secrets_dir, manifest)?;
|
||||
|
||||
let mut facts = self.detect_host_facts();
|
||||
// Only pay the podman cost to detect Knots-vs-Core when this manifest
|
||||
// actually templates the Bitcoin node into its env (mempool — B12).
|
||||
@@ -3120,6 +2860,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let mut state = self.state.write().await;
|
||||
state.disabled.remove(app_id);
|
||||
}
|
||||
// Installing is an explicit "I want this running" action — clear the
|
||||
// user-stopped marker so the new reconcile guard in
|
||||
// `ensure_running_with_mode` doesn't skip the very container we're
|
||||
// installing. (start/restart RPC handlers clear it on their side too.)
|
||||
crate::crash_recovery::clear_user_stopped(&self.data_dir, app_id).await;
|
||||
// Idempotent: if the container is already up and healthy, just
|
||||
// refresh hooks and return. If it's stopped, start it. If it's
|
||||
// missing or in a wedged state, install fresh.
|
||||
@@ -3163,6 +2908,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let mut state = self.state.write().await;
|
||||
state.disabled.remove(app_id);
|
||||
}
|
||||
// Explicit start clears the user-stopped marker so the reconcile guard in
|
||||
// `ensure_running_with_mode` doesn't skip this container (symmetric with
|
||||
// install; the start/restart RPC handlers also clear it).
|
||||
crate::crash_recovery::clear_user_stopped(&self.data_dir, app_id).await;
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let action = self.ensure_running(&lm).await?;
|
||||
match action {
|
||||
@@ -3193,13 +2942,25 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
// Per-app graceful-stop grace: manifest `stop_grace_secs` if declared,
|
||||
// else the historical per-app table. Slow-to-SIGTERM apps (bitcoin-core
|
||||
// 600s, lnd 330s, electrumx 300s, fedimint 60s…) otherwise get a too-short
|
||||
// `podman stop -t` and the stop is reported failed while the container
|
||||
// keeps running. See PRODUCTION-MASTER-PLAN §8b.
|
||||
let grace_secs = resolve_stop_grace_secs(&lm.manifest, &name);
|
||||
// Quadlet-owned containers are restarted by systemd if only `podman stop`
|
||||
// is used. Stop the user service first, then stop the container as a
|
||||
// defensive fallback for legacy/non-Quadlet installs.
|
||||
if let Err(err) = quadlet::stop_service(&format!("{name}.service")).await {
|
||||
// defensive fallback for legacy/non-Quadlet installs. Give systemd the
|
||||
// per-app grace before it force-kills the app-scoped unit.
|
||||
let quadlet_timeout = std::time::Duration::from_secs(
|
||||
grace_secs + archipelago_container::runtime::STOP_GRACE_DEADLINE_BUFFER_SECS,
|
||||
);
|
||||
if let Err(err) =
|
||||
quadlet::stop_service_with_timeout(&format!("{name}.service"), quadlet_timeout).await
|
||||
{
|
||||
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
|
||||
}
|
||||
match self.runtime.stop_container(&name).await {
|
||||
match self.runtime.stop_container_with_grace(&name, grace_secs).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let stuck_stopping = self
|
||||
@@ -3652,6 +3413,83 @@ app:
|
||||
assert!(required.contains("archy-nbxplorer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_accepts_valid_image_manifest() {
|
||||
let v = serde_json::to_value(pull_manifest("demo", "registry/demo:1.0.0")).unwrap();
|
||||
let m = catalog_manifest_to_overlay("demo", v).expect("valid image manifest accepted");
|
||||
assert_eq!(m.app.id, "demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_rejects_app_id_mismatch() {
|
||||
let v = serde_json::to_value(pull_manifest("demo", "registry/demo:1.0.0")).unwrap();
|
||||
assert!(catalog_manifest_to_overlay("other", v).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_defers_build_source_to_disk() {
|
||||
// Build contexts aren't registry-distributed yet (phase 1 = image-only).
|
||||
let v = serde_json::to_value(build_manifest("demo", "ctx", "demo:1.0.0")).unwrap();
|
||||
assert!(catalog_manifest_to_overlay("demo", v).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_rejects_invalid_manifest() {
|
||||
// Deserializes (image and build both absent) but fails validate().
|
||||
let v = serde_json::json!({
|
||||
"app": { "id": "demo", "name": "demo", "version": "1.0.0", "container": {} }
|
||||
});
|
||||
assert!(catalog_manifest_to_overlay("demo", v).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_rejects_unparseable_value() {
|
||||
let v = serde_json::json!({ "not": "a manifest" });
|
||||
assert!(catalog_manifest_to_overlay("demo", v).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_overlay_accepts_all_real_image_manifests() {
|
||||
// Guard the registry-distribution round-trip for the WHOLE shipped app
|
||||
// set: every apps/*/manifest.yml must deserialize + validate when carried
|
||||
// through the catalog as a value. Image-only apps must be accepted;
|
||||
// build-source apps must defer to disk (phase 1 = image-only). Catches
|
||||
// schema drift between disk manifests and the catalog path.
|
||||
let apps_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
if !apps_dir.exists() {
|
||||
return; // packaged/CI layout without the repo apps/ tree — skip
|
||||
}
|
||||
let mut image_apps = 0;
|
||||
for entry in std::fs::read_dir(&apps_dir).unwrap().flatten() {
|
||||
let mf = entry.path().join("manifest.yml");
|
||||
if !mf.exists() {
|
||||
continue;
|
||||
}
|
||||
// Every shipped manifest MUST be valid. load_manifests() silently
|
||||
// skips malformed ones in prod, which once let an invalid app.version
|
||||
// ("release", no digit) ship — the app then vanished from the
|
||||
// orchestrator and a stack install half-fell-back to the legacy path.
|
||||
// Fail loudly here instead.
|
||||
let m = AppManifest::from_file(&mf).unwrap_or_else(|e| {
|
||||
panic!("shipped manifest {} must be valid: {e}", mf.display())
|
||||
});
|
||||
let id = m.app.id.clone();
|
||||
let is_build = m.app.container.build.is_some();
|
||||
let value = serde_json::to_value(&m).expect("manifest serializes to JSON");
|
||||
let overlay = catalog_manifest_to_overlay(&id, value);
|
||||
if is_build {
|
||||
assert!(overlay.is_none(), "{id}: build-source app must defer to disk");
|
||||
} else {
|
||||
assert!(
|
||||
overlay.is_some(),
|
||||
"{id}: image-only app must round-trip through the catalog"
|
||||
);
|
||||
image_apps += 1;
|
||||
}
|
||||
}
|
||||
assert!(image_apps > 0, "expected at least one image-only manifest");
|
||||
}
|
||||
|
||||
fn manifest_with_container_name(id: &str, image: &str, name: &str) -> AppManifest {
|
||||
let yaml = format!(
|
||||
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container_name: {name}\n container:\n image: {image}\n"
|
||||
@@ -3687,6 +3525,37 @@ app:
|
||||
assert_eq!(compute_container_name(&m), "legacy-bitcoin-ui");
|
||||
}
|
||||
|
||||
fn manifest_with_stop_grace(id: &str, grace: &str) -> AppManifest {
|
||||
let yaml = format!(
|
||||
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n stop_grace_secs: {grace}\n container:\n image: foo:1\n"
|
||||
);
|
||||
AppManifest::parse(&yaml).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_manifest_field_wins() {
|
||||
// An explicit stop_grace_secs overrides the per-app table (fedimint=60).
|
||||
let m = manifest_with_stop_grace("fedimint", "180");
|
||||
assert_eq!(resolve_stop_grace_secs(&m, "fedimint"), 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_falls_back_to_table() {
|
||||
// No manifest field → the historical per-app table by container name.
|
||||
let m = pull_manifest("fedimint", "foo:1");
|
||||
assert_eq!(resolve_stop_grace_secs(&m, "fedimint"), 60);
|
||||
let m = pull_manifest("bitcoin-knots", "foo:1");
|
||||
assert_eq!(resolve_stop_grace_secs(&m, "bitcoin-knots"), 600);
|
||||
let m = pull_manifest("electrumx", "foo:1");
|
||||
assert_eq!(resolve_stop_grace_secs(&m, "electrumx"), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_unknown_app_defaults_to_30() {
|
||||
let m = pull_manifest("some-unknown-app", "foo:1");
|
||||
assert_eq!(resolve_stop_grace_secs(&m, "some-unknown-app"), 30);
|
||||
}
|
||||
|
||||
async fn orch_with(runtime: Arc<MockRuntime>) -> ProdContainerOrchestrator {
|
||||
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||
runtime,
|
||||
|
||||
@@ -227,13 +227,20 @@ impl QuadletUnit {
|
||||
mode
|
||||
);
|
||||
}
|
||||
for (host, container, proto) in &self.ports {
|
||||
let p = if proto.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
proto.as_str()
|
||||
};
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
// Host networking exposes the container's ports on the host directly.
|
||||
// Podman rejects PublishPort combined with Network=host ("published
|
||||
// ports cannot be used with host network") and the unit crash-loops
|
||||
// (exit 125). Skip publishing in host mode — matches the NetworkMode
|
||||
// doc note that Podman discards port mappings under host networking.
|
||||
if !matches!(self.network, NetworkMode::Host) {
|
||||
for (host, container, proto) in &self.ports {
|
||||
let p = if proto.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
proto.as_str()
|
||||
};
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
}
|
||||
}
|
||||
for env in &self.environment {
|
||||
// env entries already arrive shaped as "KEY=VALUE"; quadlet
|
||||
@@ -403,7 +410,18 @@ impl QuadletUnit {
|
||||
environment: app.environment.clone(),
|
||||
devices: app.devices.clone(),
|
||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||
network_aliases: vec![name.to_string()],
|
||||
// Container always answers to its own name; manifest extras add the
|
||||
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
||||
// Only emitted for Bridge networks (slirp/pasta reject aliases).
|
||||
network_aliases: {
|
||||
let mut a = vec![name.to_string()];
|
||||
for extra in &app.container.network_aliases {
|
||||
if !a.iter().any(|x| x == extra) {
|
||||
a.push(extra.clone());
|
||||
}
|
||||
}
|
||||
a
|
||||
},
|
||||
entrypoint: app.container.entrypoint.clone(),
|
||||
command: app.container.custom_args.clone(),
|
||||
read_only_root: app.security.readonly_root,
|
||||
@@ -624,7 +642,17 @@ pub async fn restart_service(service: &str) -> Result<()> {
|
||||
|
||||
/// Stop a generated Quadlet service without removing its unit file.
|
||||
pub async fn stop_service(service: &str) -> Result<()> {
|
||||
match systemctl_user_status(&["stop", service], QUADLET_STOP_TIMEOUT).await {
|
||||
stop_service_with_timeout(service, QUADLET_STOP_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Stop a user service, waiting up to `timeout` for a graceful stop before
|
||||
/// force-killing the app-scoped unit. Slow-to-SIGTERM apps (bitcoin-core ~600s,
|
||||
/// lnd ~330s) must not be SIGKILLed at the default 45s — that risks data
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
Err(err) => {
|
||||
@@ -852,6 +880,26 @@ mod tests {
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_host_network_omits_publish_ports() {
|
||||
// Podman rejects PublishPort with Network=host (crash-loop exit 125).
|
||||
let mut u = sample_unit();
|
||||
u.network = NetworkMode::Host;
|
||||
u.ports = vec![(3000, 3000, "tcp".into())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("Network=host"));
|
||||
assert!(!s.contains("PublishPort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_non_host_network_emits_publish_ports() {
|
||||
let mut u = sample_unit();
|
||||
u.network = NetworkMode::Bridge("archy-net".into());
|
||||
u.ports = vec![(3000, 3000, "tcp".into())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_filename_and_service_name_are_consistent() {
|
||||
let u = sample_unit();
|
||||
@@ -1033,6 +1081,7 @@ app:
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: registry/bitcoin-knots:1.0
|
||||
network: archy-net
|
||||
entrypoint: ["/usr/local/bin/bitcoind"]
|
||||
custom_args: ["-server=1", "-rpcbind=0.0.0.0"]
|
||||
ports:
|
||||
@@ -1053,7 +1102,7 @@ app:
|
||||
security:
|
||||
capabilities: ["NET_BIND_SERVICE"]
|
||||
readonly_root: true
|
||||
network_policy: archy-net
|
||||
network_policy: isolated
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let u = QuadletUnit::from_manifest(&m, "bitcoin-knots");
|
||||
@@ -1193,7 +1242,7 @@ app:
|
||||
image: x:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /etc/host-conf
|
||||
source: /var/lib/archipelago/x-conf
|
||||
target: /etc/conf
|
||||
options: ["ro"]
|
||||
"#;
|
||||
@@ -1217,7 +1266,7 @@ app:
|
||||
target: /tmp
|
||||
tmpfs_options: "rw,size=64m"
|
||||
- type: bind
|
||||
source: /var/lib/x
|
||||
source: /var/lib/archipelago/x
|
||||
target: /data
|
||||
options: []
|
||||
"#;
|
||||
@@ -1225,7 +1274,7 @@ app:
|
||||
let u = QuadletUnit::from_manifest(&m, "x");
|
||||
// tmpfs entry is dropped from bind_mounts; bind entry survives.
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/x"));
|
||||
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/archipelago/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1404,6 +1453,31 @@ app:
|
||||
assert!(!publish_ports_changed(new, new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_appends_manifest_network_aliases_for_bridge() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: indeedhub-api
|
||||
name: IndeedHub API
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: registry/indeedhub-api:1.0.0
|
||||
network: indeedhub-net
|
||||
network_aliases: [api]
|
||||
security:
|
||||
capabilities: []
|
||||
network_policy: isolated
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let u = QuadletUnit::from_manifest(&m, "indeedhub-api");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "indeedhub-net"));
|
||||
// Own name first, then the baked-in short alias the frontend nginx uses.
|
||||
assert_eq!(u.network_aliases, vec!["indeedhub-api", "api"]);
|
||||
let s = u.render();
|
||||
assert!(s.contains("NetworkAlias=api"));
|
||||
assert!(s.contains("PodmanArgs=--network-alias=api"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_aliases_changed_detects_service_discovery_drift() {
|
||||
let old = "[Container]\nNetwork=archy-net\n";
|
||||
@@ -1462,6 +1536,7 @@ app:
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: registry/lnd:latest
|
||||
network: archy-net
|
||||
ports:
|
||||
- host: 10009
|
||||
container: 10009
|
||||
@@ -1477,7 +1552,7 @@ app:
|
||||
memory_limit: 1g
|
||||
security:
|
||||
capabilities: []
|
||||
network_policy: archy-net
|
||||
network_policy: isolated
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
let body = QuadletUnit::from_manifest(&m, "lnd").render();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Declarative, self-healing generation of app secrets.
|
||||
//!
|
||||
//! An app declares `generated_secrets` in its manifest; this module materialises
|
||||
//! them just before `secret_env` is resolved. That keeps the migration's
|
||||
//! data-driven bar: an app installs from its manifest alone — no host
|
||||
//! provisioning and no per-app Rust — and every secret lands `0600`, owned by
|
||||
//! the unprivileged (rootless) service user.
|
||||
//!
|
||||
//! Two properties make it safe to call on every install/reconcile tick:
|
||||
//!
|
||||
//! * **Idempotent** — a target file that already exists, is readable and
|
||||
//! non-empty is left untouched, so values are stable across ticks.
|
||||
//! * **Self-healing without privilege** — a target file that exists but is
|
||||
//! *unreadable* (the classic `root:root`-owned secret left by some earlier
|
||||
//! path) is unlinked and rewritten. Unlinking needs write on the
|
||||
//! service-owned secrets dir, not on the file, so this recovers the broken
|
||||
//! state with no `chown` and no root — exactly what a rootless node needs.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::{AppManifest, GeneratedSecret, SecretGenKind};
|
||||
use rand::RngCore;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
/// Plaintext-password length (bytes of entropy) for [`SecretGenKind::Bcrypt`].
|
||||
const BCRYPT_PASSWORD_BYTES: usize = 24;
|
||||
|
||||
/// Materialise every declared generated secret for `manifest` under
|
||||
/// `secrets_dir`. No-op when the manifest declares none. Safe to call on every
|
||||
/// reconcile/install tick (idempotent + self-healing).
|
||||
pub fn ensure_generated_secrets(secrets_dir: &Path, manifest: &AppManifest) -> Result<()> {
|
||||
let specs = &manifest.app.container.generated_secrets;
|
||||
if specs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir_all(secrets_dir)
|
||||
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
|
||||
for gs in specs {
|
||||
ensure_one(secrets_dir, gs).with_context(|| format!("generating secret '{}'", gs.name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> {
|
||||
let files = gs.target_files();
|
||||
|
||||
// Idempotent fast path: every target file present, readable and non-empty.
|
||||
if files.iter().all(|f| readable_nonempty(&dir.join(f))) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Self-heal: drop any stale/unreadable target so the write below recreates
|
||||
// it owned by us. Unlinking uses the (service-owned) dir's write bit, so a
|
||||
// wrongly root-owned secret is recovered with no privilege escalation.
|
||||
for f in &files {
|
||||
let p = dir.join(f);
|
||||
if p.exists() && !readable_nonempty(&p) {
|
||||
tracing::warn!("regenerating unreadable/stale secret {}", p.display());
|
||||
fs::remove_file(&p)
|
||||
.with_context(|| format!("removing stale secret {}", p.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
match gs.kind {
|
||||
SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?,
|
||||
SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?,
|
||||
SecretGenKind::Bcrypt => {
|
||||
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||
.context("bcrypt-hashing generated password")?;
|
||||
// Primary (server-facing hash) first, then the plaintext sibling.
|
||||
write_secret(&dir.join(&gs.name), &hash)?;
|
||||
write_secret(&dir.join(format!("{}.pw", gs.name)), &password)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True when `path` exists, is readable by this process, and is non-empty after
|
||||
/// trimming. Any error (missing, permission denied, empty) reads as false.
|
||||
fn readable_nonempty(path: &Path) -> bool {
|
||||
fs::read_to_string(path)
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn random_hex(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
hex::encode(buf)
|
||||
}
|
||||
|
||||
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
||||
/// is atomic), fsynced, then renamed over the target.
|
||||
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
||||
let dir = path
|
||||
.parent()
|
||||
.context("secret path has no parent directory")?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("secret path has no filename")?;
|
||||
let tmp = dir.join(format!(".{name}.tmp"));
|
||||
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("creating temp secret {}", tmp.display()))?;
|
||||
f.write_all(value.as_bytes())
|
||||
.with_context(|| format!("writing temp secret {}", tmp.display()))?;
|
||||
f.sync_all()
|
||||
.with_context(|| format!("fsync temp secret {}", tmp.display()))?;
|
||||
drop(f);
|
||||
|
||||
fs::rename(&tmp, path)
|
||||
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use archipelago_container::SecretGenKind;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn manifest_with(secrets: Vec<GeneratedSecret>) -> AppManifest {
|
||||
let mut m: AppManifest = serde_yaml::from_str(
|
||||
"app:\n id: t\n name: t\n version: 1.0.0\n container:\n image: x:y\n",
|
||||
)
|
||||
.unwrap();
|
||||
m.app.container.generated_secrets = secrets;
|
||||
m
|
||||
}
|
||||
|
||||
fn gs(name: &str, kind: SecretGenKind) -> GeneratedSecret {
|
||||
GeneratedSecret {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_hex_and_bcrypt_with_0600() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let m = manifest_with(vec![
|
||||
gs("tok", SecretGenKind::Hex16),
|
||||
gs("admin", SecretGenKind::Bcrypt),
|
||||
]);
|
||||
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||
|
||||
let tok = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
assert_eq!(tok.trim().len(), 32, "hex16 = 16 bytes = 32 hex chars");
|
||||
|
||||
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
|
||||
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
|
||||
assert!(hash.starts_with("$2"), "bcrypt hash shape");
|
||||
assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash");
|
||||
|
||||
for f in ["tok", "admin", "admin.pw"] {
|
||||
let mode = std::fs::metadata(dir.path().join(f))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600, "{f} must be 0600");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_value_is_stable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex32)]);
|
||||
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
assert_eq!(first, second, "a present readable secret is never rewritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_heals_unreadable_secret() {
|
||||
// Simulate the root-owned case: a present-but-unreadable file. We can't
|
||||
// chmod-away read as the owner in a unit test, so emulate "unreadable"
|
||||
// via the empty-file branch (readable_nonempty == false), which drives
|
||||
// the same unlink+regenerate path.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("tok"), "").unwrap();
|
||||
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex16)]);
|
||||
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||
let v = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
assert_eq!(v.trim().len(), 32, "stale/empty secret was regenerated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Buyer-side store of paid content the node has purchased.
|
||||
//!
|
||||
//! A paid peer download used to be ephemeral: the bytes were handed to the
|
||||
//! browser as a one-shot `<a download>` and then thrown away. On the mobile
|
||||
//! companion that download silently fails, so the item appeared to never
|
||||
//! "unlock" even though the ecash was spent. This module persists every
|
||||
//! successful purchase — bytes + metadata — keyed by (seller onion, content_id),
|
||||
//! so the gallery can render owned items unblurred and play/view them in-app
|
||||
//! from the local cache, with no re-payment and no reliance on a browser
|
||||
//! download. The buyer can still save the file later from the cached copy.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const OWNED_DIR: &str = "purchased-content";
|
||||
const OWNED_INDEX: &str = "owned.json";
|
||||
|
||||
/// One purchased item. `onion` + `content_id` are the identity; everything else
|
||||
/// is display/metadata captured at purchase time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OwnedItem {
|
||||
pub onion: String,
|
||||
pub content_id: String,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: u64,
|
||||
pub paid_sats: u64,
|
||||
pub ecash_backend: String,
|
||||
/// RFC3339 timestamp; best-effort, empty if the clock was unavailable.
|
||||
pub purchased_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct OwnedIndex {
|
||||
items: Vec<OwnedItem>,
|
||||
}
|
||||
|
||||
fn owned_root(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(OWNED_DIR)
|
||||
}
|
||||
|
||||
fn index_path(data_dir: &Path) -> PathBuf {
|
||||
owned_root(data_dir).join(OWNED_INDEX)
|
||||
}
|
||||
|
||||
/// Sanitize an onion into a safe directory component (it's already [a-z2-7].onion
|
||||
/// for valid v3, but be defensive against path traversal regardless).
|
||||
fn sanitize(component: &str) -> String {
|
||||
component
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||
owned_root(data_dir)
|
||||
.join(sanitize(onion))
|
||||
.join(sanitize(content_id))
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
match fs::read_to_string(index_path(data_dir)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => OwnedIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||
let root = owned_root(data_dir);
|
||||
fs::create_dir_all(&root)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", root.display()))?;
|
||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||
fs::write(index_path(data_dir), content)
|
||||
.await
|
||||
.context("writing owned index")
|
||||
}
|
||||
|
||||
/// Persist a successful purchase: write the bytes to disk and upsert the index
|
||||
/// entry. Idempotent on (onion, content_id) — re-buying overwrites with the
|
||||
/// latest copy/metadata rather than duplicating.
|
||||
pub async fn record_purchase(
|
||||
data_dir: &Path,
|
||||
onion: &str,
|
||||
content_id: &str,
|
||||
filename: &str,
|
||||
mime_type: &str,
|
||||
bytes: &[u8],
|
||||
paid_sats: u64,
|
||||
ecash_backend: &str,
|
||||
purchased_at: &str,
|
||||
) -> Result<()> {
|
||||
let path = bytes_path(data_dir, onion, content_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
fs::write(&path, bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||
|
||||
let mut index = load_index(data_dir).await;
|
||||
let entry = OwnedItem {
|
||||
onion: onion.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
mime_type: mime_type.to_string(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
paid_sats,
|
||||
ecash_backend: ecash_backend.to_string(),
|
||||
purchased_at: purchased_at.to_string(),
|
||||
};
|
||||
if let Some(existing) = index
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||
{
|
||||
*existing = entry;
|
||||
} else {
|
||||
index.items.push(entry);
|
||||
}
|
||||
save_index(data_dir, &index).await
|
||||
}
|
||||
|
||||
/// Every item this node owns.
|
||||
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||
load_index(data_dir).await.items
|
||||
}
|
||||
|
||||
/// True if the node has already purchased this (onion, content_id).
|
||||
#[allow(dead_code)] // used by the upcoming seller-side signed-entitlement path (#8)
|
||||
pub async fn is_owned(data_dir: &Path, onion: &str, content_id: &str) -> bool {
|
||||
bytes_path(data_dir, onion, content_id).exists()
|
||||
&& load_index(data_dir)
|
||||
.await
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| i.onion == onion && i.content_id == content_id)
|
||||
}
|
||||
|
||||
/// Read a purchased item's bytes + mime type from the local cache, if present.
|
||||
pub async fn read_owned(
|
||||
data_dir: &Path,
|
||||
onion: &str,
|
||||
content_id: &str,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?;
|
||||
let mime = load_index(data_dir)
|
||||
.await
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||
.map(|i| i.mime_type)
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
Some((mime, bytes))
|
||||
}
|
||||
@@ -254,11 +254,57 @@ pub(crate) async fn notify_join(
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_json(&body)
|
||||
.await;
|
||||
// Deliver the notification in the BACKGROUND with retries, and return
|
||||
// immediately. Two reasons:
|
||||
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
|
||||
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
|
||||
// the whole join until FIPS timed out, surfacing as "Request timeout"
|
||||
// in the UI even though the local membership was already saved.
|
||||
// 2. If this single best-effort POST failed, the inviter never learned
|
||||
// about us → asymmetric federation (they couldn't see us). Retrying in
|
||||
// the background until it lands makes federation converge to symmetric.
|
||||
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
|
||||
// answers an onion in ~3-5s) is reached quickly on each attempt.
|
||||
let remote_onion = remote_onion.to_string();
|
||||
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
|
||||
tokio::spawn(async move {
|
||||
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
|
||||
// peer that is briefly unreachable (restarting, publishing its onion)
|
||||
// without hammering it.
|
||||
for attempt in 1..=5u32 {
|
||||
let res = crate::fips::dial::PeerRequest::new(
|
||||
remote_fips_npub.as_deref(),
|
||||
&remote_onion,
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&body)
|
||||
.await;
|
||||
match res {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
tracing::info!(
|
||||
attempt,
|
||||
transport = %transport,
|
||||
"peer-joined notification delivered to inviter"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok((resp, _)) => tracing::warn!(
|
||||
attempt,
|
||||
status = %resp.status(),
|
||||
"peer-joined notification rejected; will retry"
|
||||
),
|
||||
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
|
||||
}
|
||||
tracing::warn!(
|
||||
onion = %remote_onion,
|
||||
"peer-joined notification gave up after retries — peer may not see us until next sync"
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ mod types;
|
||||
|
||||
// Re-export all public items so `crate::federation::*` continues to work.
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
// Crate-internal: used by the periodic federation auto-sync to re-assert
|
||||
// membership to peers that don't list us back (asymmetry self-heal).
|
||||
pub(crate) use invites::notify_join;
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
|
||||
@@ -33,6 +33,12 @@ pub async fn sync_with_peer(
|
||||
.header("X-Federation-Sig", signature)
|
||||
.header("X-Federation-Timestamp", timestamp)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
|
||||
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
|
||||
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
|
||||
// budget. Without this, a state sync to a FIPS-unreachable peer "took
|
||||
// ages" and join/sync appeared to time out even though Tor was healthy.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&body)
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
@@ -308,6 +308,14 @@ pub struct PeerRequest<'a> {
|
||||
pub path: &'a str,
|
||||
pub headers: Vec<(&'a str, String)>,
|
||||
pub timeout: std::time::Duration,
|
||||
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
|
||||
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
|
||||
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
|
||||
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
|
||||
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
|
||||
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
|
||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||
pub fips_timeout: Option<std::time::Duration>,
|
||||
pub service: Option<crate::settings::transport::PeerService>,
|
||||
}
|
||||
|
||||
@@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> {
|
||||
path,
|
||||
headers: Vec::new(),
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
|
||||
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
|
||||
/// budget. Use on short request/response calls (invoice, status); leave
|
||||
/// unset for large downloads that legitimately need a long FIPS transfer.
|
||||
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
|
||||
self.fips_timeout = Some(t);
|
||||
self
|
||||
}
|
||||
|
||||
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
|
||||
/// overall request timeout.
|
||||
fn fips_attempt_timeout(&self) -> std::time::Duration {
|
||||
self.fips_timeout.unwrap_or(self.timeout)
|
||||
}
|
||||
|
||||
/// Tie this request to a user-configurable service preference. If
|
||||
/// the user has set that service to `Fips` or `Tor`, the builder
|
||||
/// respects it.
|
||||
@@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
@@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
|
||||
@@ -60,14 +60,23 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
tracing::info!("FIPS auto-activated");
|
||||
}
|
||||
|
||||
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
|
||||
/// Spawn the FIPS supervisor: every 25s it (1) auto-activates FIPS if onboarding
|
||||
/// is done but the service is down — so it comes up with zero user interaction,
|
||||
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
||||
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
||||
/// so one slow/offline peer doesn't delay the rest.
|
||||
///
|
||||
/// The interval MUST be shorter than the NAT/hole-punch cold window
|
||||
/// (`warm_path` docs it at ~30-60s). The previous 45s sat at the edge of that
|
||||
/// window: a path that went cold at ~30s stayed cold until the next 45s tick,
|
||||
/// so real peer dials in that gap hit a cold path and fell back to Tor (~18s
|
||||
/// onion latency instead of FIPS's ~2-3s). 25s keeps every path refreshed
|
||||
/// inside the minimum cold window, which is what actually makes FIPS — not Tor —
|
||||
/// the transport peer requests land on. Measured: warm FIPS browse ~2.6s vs a
|
||||
/// cold-path fallback browse ~18-22s over Tor to the same peer.
|
||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(45));
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||
|
||||
@@ -39,6 +39,7 @@ mod constants;
|
||||
mod container;
|
||||
mod content_hash;
|
||||
mod content_invoice;
|
||||
mod content_owned;
|
||||
mod content_server;
|
||||
mod crash_recovery;
|
||||
mod credentials;
|
||||
@@ -200,11 +201,32 @@ async fn main() -> Result<()> {
|
||||
// Best-effort manifest load; a missing /opt/archipelago/apps is
|
||||
// logged inside load_manifests and not fatal.
|
||||
match prod.load_manifests().await {
|
||||
Ok(n) => info!("📦 Loaded {n} app manifest(s) from disk"),
|
||||
Ok(n) => info!("📦 Loaded {n} app manifest(s) (disk + registry catalog)"),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "prod orchestrator: load_manifests failed at startup");
|
||||
}
|
||||
}
|
||||
// Reboot-survival safety net for the podman `--restart` path: ensure the
|
||||
// user's podman-restart.service is enabled so `unless-stopped` containers
|
||||
// come back after a reboot even when the Quadlet backend path is off
|
||||
// (orchestrator-installed backends like immich/btcpay run as plain podman
|
||||
// containers until the Phase-3 Quadlet rollout). Idempotent + best-effort.
|
||||
{
|
||||
let out = tokio::process::Command::new("systemctl")
|
||||
.args(["--user", "enable", "--now", "podman-restart.service"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("🔁 podman-restart.service enabled (reboot-survival for --restart containers)")
|
||||
}
|
||||
Ok(o) => tracing::debug!(
|
||||
"podman-restart.service enable skipped: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::debug!("podman-restart.service enable skipped: {e}"),
|
||||
}
|
||||
}
|
||||
// Adoption pass: link existing podman containers back to their
|
||||
// manifests so the reconciler doesn't recreate them.
|
||||
match tokio::time::timeout(Duration::from_secs(35), prod.adopt_existing()).await {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! asker is limited to one in-flight query.
|
||||
|
||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||
use super::super::types::MeshEvent;
|
||||
use super::bitcoin::send_to_peer;
|
||||
use super::{MeshCommand, MeshState};
|
||||
use crate::federation::TrustLevel;
|
||||
@@ -42,28 +43,46 @@ pub(super) enum AssistReply {
|
||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||
ChannelText { channel: u8 },
|
||||
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
|
||||
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
|
||||
/// direct message, so the answer lands inline in that same conversation
|
||||
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||
ChatText { contact_id: u32 },
|
||||
/// Plain-text NATIVE direct message back to the asker's radio contact —
|
||||
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
|
||||
/// answer goes as a real unicast DM (not a public-channel broadcast), so
|
||||
/// only the asker sees it and a stock client can read it.
|
||||
RadioDm { dest_prefix: [u8; 6] },
|
||||
}
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
// Whether the asker's message was cryptographically authenticated (a
|
||||
// verified signature, or arrival over the federation transport). Required
|
||||
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker).await {
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Record who was turned away so the operator can find + allow them from
|
||||
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||
record_denied(&state, asker, &sender_name).await;
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
@@ -144,13 +163,25 @@ pub(super) async fn run_assist(
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
||||
/// federation-Trusted match on the peer's pubkey or DID.
|
||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
||||
///
|
||||
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||
/// i.e. the asker's message carried a signature that verified against its known
|
||||
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||
async fn is_sender_allowed(
|
||||
state: &Arc<MeshState>,
|
||||
sender_contact_id: u32,
|
||||
authenticated: bool,
|
||||
) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
@@ -169,11 +200,35 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: the operator deliberately ticked THIS
|
||||
// contact, so honour it even for an unauthenticated radio asker. A stock
|
||||
// meshcore client (e.g. a phone) can't sign our typed envelopes, so it can
|
||||
// never be `authenticated` — gating the allowlist on authentication made
|
||||
// ticking such a contact have no effect. We match the asker's resolved
|
||||
// identity key: the bound archipelago key if we know it, else the firmware
|
||||
// routing key (`pubkey_hex`), which is how meshcore addresses the contact
|
||||
// and what the UI adds to the allowlist for a keyless radio peer. This is a
|
||||
// narrow, explicit opt-in for a specific key — the spoofable federation-
|
||||
// trust-list match below still requires authentication.
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only: match against the federation trust list.
|
||||
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||
if !authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against the federation trust list by the asker's verified archipelago
|
||||
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -183,6 +238,36 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||
/// who recently tried, without unbounded growth from a spammer.
|
||||
const MAX_DENIED_ASKERS: usize = 25;
|
||||
|
||||
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||
let pubkey_hex = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&asker_contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
};
|
||||
let entry = super::DeniedAsker {
|
||||
contact_id: asker_contact_id,
|
||||
name: sender_name.to_string(),
|
||||
pubkey_hex,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let mut denied = state.assist_denied.write().await;
|
||||
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||
denied.push_front(entry);
|
||||
denied.truncate(MAX_DENIED_ASKERS);
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
@@ -205,6 +290,19 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
||||
let text = cap_channel(answer);
|
||||
send_channel_text(state, *channel, &text).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_chat_text(state, *contact_id, &text).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let text = cap_channel(answer);
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: text.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +322,17 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
|
||||
AssistReply::ChannelText { channel } => {
|
||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: format!("AI: {msg}").into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +381,23 @@ async fn send_typed_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
|
||||
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
|
||||
/// than sending here, because the reply must be routed transport-aware:
|
||||
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
|
||||
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
|
||||
/// to POST over the peer's onion for a federation-synthetic contact_id. The
|
||||
/// radio-only path used to drop the reply for federation askers — the answer
|
||||
/// showed on the answering node but never reached the asker. A server-layer
|
||||
/// consumer fulfils this event via `send_message`, which also records the
|
||||
/// Sent bubble and allocates the seq.
|
||||
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
|
||||
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||
let _ = state
|
||||
|
||||
@@ -353,26 +353,40 @@ pub(super) async fn store_plain_message(
|
||||
state.status.write().await.messages_received += 1;
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
||||
// channel is answered by this node's local model when the assistant is on.
|
||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
||||
// see it. The trust/rate gate lives in run_assist.
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||
// by this node's local model when the assistant is on. The trust/rate gate
|
||||
// lives in run_assist. The reply goes back as a private NATIVE DM to the
|
||||
// asker whenever we know its radio pubkey (so it does NOT land on the public
|
||||
// channel and a stock meshcore client can read it); we only fall back to a
|
||||
// channel reply if the sender has no resolvable pubkey (rare).
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = strip_ai_trigger(text) {
|
||||
if !prompt.is_empty() {
|
||||
let reply = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.pubkey_hex.clone())
|
||||
.filter(|h| h.len() >= 12)
|
||||
.and_then(|h| hex::decode(&h[..12]).ok())
|
||||
.filter(|b| b.len() == 6)
|
||||
.map(|b| {
|
||||
let mut pre = [0u8; 6];
|
||||
pre.copy_from_slice(&b);
|
||||
super::assist::AssistReply::RadioDm { dest_prefix: pre }
|
||||
})
|
||||
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
|
||||
};
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
// A bare plain-text channel `!ai` carries no signature, so it
|
||||
// is NOT authenticated — under trusted_only it'll be denied,
|
||||
// and it can only be answered under the "anyone" policy.
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
contact_id,
|
||||
name,
|
||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
||||
st,
|
||||
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -383,7 +397,7 @@ pub(super) async fn store_plain_message(
|
||||
|
||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||
fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
let t = text.trim_start();
|
||||
for p in ["!ai ", "!ask "] {
|
||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||
@@ -475,11 +489,18 @@ pub(super) async fn handle_identity_received(
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
// We just heard this peer's identity advert, so it's reachable.
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
|
||||
@@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
sender_name: &str,
|
||||
envelope: TypedEnvelope,
|
||||
) {
|
||||
// Verify envelope signature if present, using the sender's known Ed25519 key
|
||||
// Verify the envelope signature (if present) against the sender's known
|
||||
// Ed25519 key, and record whether the sender is cryptographically
|
||||
// authenticated. A federation peer (synthetic high-half contact_id) arrived
|
||||
// over the Tor relay, which verifies the sender signature upstream before
|
||||
// injecting here, so it counts as authenticated. This flag gates the
|
||||
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
|
||||
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
|
||||
if envelope.sig.is_some() {
|
||||
let peer_pubkey = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&sender_contact_id)
|
||||
.and_then(|p| p.pubkey_hex.as_ref())
|
||||
// Verify against the bound archipelago identity key, not the
|
||||
// firmware routing key — only the former is what the peer signs with.
|
||||
.and_then(|p| p.identity_pubkey_hex())
|
||||
.and_then(|hex_str| hex::decode(hex_str).ok())
|
||||
.and_then(|bytes| {
|
||||
if bytes.len() == 32 {
|
||||
@@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
});
|
||||
if let Some(vk) = peer_pubkey {
|
||||
match envelope.verify_signature(&vk) {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
authenticated = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
peer = sender_contact_id,
|
||||
@@ -679,6 +689,37 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
|
||||
// the normal 1:1 chat triggers this node's assistant, with the
|
||||
// answer sent back as a chat bubble in the same thread. The typed
|
||||
// DM carries the peer's federation identity (via sender_contact_id),
|
||||
// so the `trusted_only` gate in run_assist resolves correctly —
|
||||
// unlike the bare channel-text path, which only knows the radio key.
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
|
||||
if !prompt.is_empty() {
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = sender_name.to_string();
|
||||
let cid = sender_contact_id;
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
cid,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||
st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistQuery) => {
|
||||
@@ -718,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
|
||||
@@ -22,8 +22,33 @@ pub(super) async fn handle_frame(
|
||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||
info!(
|
||||
code = frame.code,
|
||||
data_len = frame.data.len(),
|
||||
"Contact discovery event — refreshing contacts"
|
||||
);
|
||||
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
|
||||
// pubkey of a node we just heard. If it isn't already a contact,
|
||||
// add it to the firmware table so it shows up immediately — no
|
||||
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
|
||||
// added by the firmware, so we skip it.)
|
||||
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
|
||||
let mut pubkey = [0u8; 32];
|
||||
pubkey.copy_from_slice(&frame.data[..32]);
|
||||
let pk_hex = hex::encode(pubkey);
|
||||
let known = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
|
||||
if !known {
|
||||
let _ = state
|
||||
.send_cmd(super::MeshCommand::AddContact {
|
||||
pubkey,
|
||||
name: String::new(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return true; // Signal caller to fetch contacts
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ pub enum MeshCommand {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
|
||||
SendNativeText {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Broadcast pre-encoded binary on a mesh channel.
|
||||
BroadcastChannel {
|
||||
channel: u8,
|
||||
@@ -71,6 +79,16 @@ pub enum MeshCommand {
|
||||
SendAdvert,
|
||||
/// Re-fetch contact list from the radio device.
|
||||
RefreshContacts,
|
||||
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
|
||||
RemoveContact {
|
||||
pubkey: [u8; 32],
|
||||
},
|
||||
/// Import/add a heard advert as a firmware contact so it shows up without
|
||||
/// needing a flood advert. Name may be empty (firmware fills from advert).
|
||||
AddContact {
|
||||
pubkey: [u8; 32],
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Shared state for the mesh listener, accessible from RPC handlers.
|
||||
@@ -135,6 +153,28 @@ pub struct MeshState {
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||
/// UI can surface them and let the operator one-click allow their key.
|
||||
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||
}
|
||||
|
||||
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||
/// the operator can add their key to the allowlist without hunting the journal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeniedAsker {
|
||||
/// Meshcore contact id of the asker.
|
||||
pub contact_id: u32,
|
||||
/// Best-known display name (advert name) at denial time.
|
||||
pub name: String,
|
||||
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// ISO-8601 timestamp of the (most recent) denial.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
@@ -148,6 +188,10 @@ pub struct AssistantConfig {
|
||||
pub trusted_only: bool,
|
||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||
pub backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
|
||||
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
|
||||
/// applies. A user-blocked contact is always denied even if listed here.
|
||||
pub allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||
@@ -226,6 +270,7 @@ impl MeshState {
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
assist_denied: RwLock::new(VecDeque::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,43 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.remove_contact(pubkey).await,
|
||||
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_contact(
|
||||
&mut self,
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => {
|
||||
device
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
Self::Meshtastic(device) => {
|
||||
device
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
@@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
|
||||
/// route inbound DMs to the correct contact_id thread.
|
||||
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
||||
|
||||
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
|
||||
fn wrap_dm_for_channel(
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
sender_arch_prefix: &[u8; 6],
|
||||
@@ -169,6 +207,7 @@ fn wrap_dm_for_channel(
|
||||
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
||||
/// caller constructed `MeshState` with a bad value — empty string yields
|
||||
/// all-zero, which won't match any real peer on the receiver side).
|
||||
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
|
||||
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
let mut out = [0u8; 6];
|
||||
if state.our_ed_pubkey_hex.len() >= 12 {
|
||||
@@ -195,39 +234,42 @@ async fn send_dm_via_channel(
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
use base64::Engine;
|
||||
let sender_prefix = our_sender_prefix(state);
|
||||
// First try a single frame with the raw payload directly wrapped.
|
||||
// This keeps small plain-text messages at minimal overhead.
|
||||
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
|
||||
if single.len() <= 140 {
|
||||
match device.send_channel_text(0, single.as_bytes()).await {
|
||||
let _ = state; // native unicast carries no separate sender prefix
|
||||
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
|
||||
// contact, NOT a broadcast on the shared public channel. This is the fix
|
||||
// for the long-standing public-channel pollution — archy used to tunnel
|
||||
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
|
||||
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
|
||||
// phone) couldn't decode. A native DM is private and decodes everywhere.
|
||||
// The receive side handles these via the existing RESP_CONTACT_MSG path.
|
||||
//
|
||||
// Small payloads send in one frame; larger ones are base64 + MC-chunked
|
||||
// and reassembled by the receiver (try_chunk_reassemble).
|
||||
if payload.len() <= 140 {
|
||||
match device.send_text_msg(dest_pubkey_prefix, payload).await {
|
||||
Ok(()) => {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
len = payload.len(),
|
||||
wire_len = single.len(),
|
||||
"Sent mesh message (DM via channel)"
|
||||
"Sent mesh DM (native unicast)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Failed to send DM via channel: {}", e
|
||||
"Failed to send native DM: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Payload too large for one wrap — base64 then MC-chunk. Receiver
|
||||
// reassembles base64 chunks and routes the decoded bytes back through
|
||||
// the typed-envelope ladder in handle_channel_payload.
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let chunk_data_size = 80;
|
||||
let chunk_data_size = 100;
|
||||
let chunks: Vec<&str> = encoded
|
||||
.as_bytes()
|
||||
.chunks(chunk_data_size)
|
||||
@@ -239,18 +281,20 @@ async fn send_dm_via_channel(
|
||||
raw_len = payload.len(),
|
||||
b64_len = encoded.len(),
|
||||
chunks = total,
|
||||
"Sending chunked mesh message (DM via channel)"
|
||||
"Sending chunked mesh DM (native unicast)"
|
||||
);
|
||||
let mut any_err = false;
|
||||
for (idx, chunk) in chunks.iter().enumerate() {
|
||||
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
||||
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
|
||||
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
|
||||
if let Err(e) = device
|
||||
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
|
||||
.await
|
||||
{
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
chunk = idx,
|
||||
"Chunk DM-via-channel send failed: {}",
|
||||
"Chunk native DM send failed: {}",
|
||||
e
|
||||
);
|
||||
any_err = true;
|
||||
@@ -263,20 +307,72 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
|
||||
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
|
||||
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
|
||||
/// split into multiple readable plain messages on UTF-8 char boundaries.
|
||||
async fn send_plain_native_text(
|
||||
device: &mut MeshRadioDevice,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
text: &[u8],
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
|
||||
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
|
||||
let s = String::from_utf8_lossy(text);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for ch in s.chars() {
|
||||
if cur.len() + ch.len_utf8() > FRAME {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.push(ch);
|
||||
}
|
||||
if !cur.is_empty() || parts.is_empty() {
|
||||
parts.push(cur);
|
||||
}
|
||||
let total = parts.len();
|
||||
for (idx, part) in parts.iter().enumerate() {
|
||||
match device
|
||||
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
part = idx + 1,
|
||||
total,
|
||||
"Sent plain native DM"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Plain native DM send failed: {}", e
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if total > 1 {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
// mesh.clear-all. MeshCore keeps its own persistent contact
|
||||
// table the app can't remove from, so we filter on read to
|
||||
// keep cleared entries out of the chat list.
|
||||
let blocklist = state.radio_contact_blocklist.read().await.clone();
|
||||
// Contact blocking is intentionally NOT applied here. A read-time
|
||||
// blocklist meant a wiped/re-paired contact could never come back
|
||||
// even when it re-advertised (it broke phone re-pairing after a
|
||||
// clear). Per-contact blocking will return later as an explicit,
|
||||
// user-controlled feature; until then every firmware contact is
|
||||
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||
let mut peers = state.peers.write().await;
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
if blocklist.contains(&contact.public_key_hex) {
|
||||
continue;
|
||||
}
|
||||
let contact_id = idx as u32;
|
||||
let existing = peers.get(&contact_id);
|
||||
let peer = super::super::types::MeshPeer {
|
||||
@@ -284,14 +380,31 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
// Preserve any archipelago identity bound by an earlier
|
||||
// identity advert — NEVER overwrite it with the firmware
|
||||
// contact key, or a signed `!ai` query from this peer would
|
||||
// fail authentication after the next contact refresh.
|
||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
last_advert: contact.last_advert,
|
||||
// A non-zero path_len means the firmware has a route (direct
|
||||
// or flood) to this contact — i.e. we can deliver to it.
|
||||
reachable: contact.path_len != 0,
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
// A radio contact that shares an exact advert_name with a known
|
||||
// federation peer is the same physical node — bind the federation
|
||||
// peer's archipelago identity onto the radio record so a signed
|
||||
// `!ai`/typed message over LoRa authenticates (and the contact stops
|
||||
// showing as a radio/federation duplicate). Security is unchanged:
|
||||
// the bound key is only a candidate the inbound signature must still
|
||||
// verify against. See `bind_federation_twins`.
|
||||
super::super::bind_federation_twins(&mut peers);
|
||||
drop(peers);
|
||||
state.update_peer_count().await;
|
||||
if !contacts.is_empty() {
|
||||
@@ -424,21 +537,16 @@ pub(super) async fn run_mesh_session(
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
|
||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
||||
our_did,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
);
|
||||
if let Err(e) = device
|
||||
.send_channel_text(0, identity_advert.as_bytes())
|
||||
.await
|
||||
{
|
||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||
// participant on that channel — including plain Meshtastic/meshcore users who
|
||||
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
|
||||
// The inbound parser in frames.rs still accepts these from any legacy peer that
|
||||
// sends them, so trust-binding keeps working when a peer advertises; we simply
|
||||
// don't pollute the public channel ourselves. A dedicated control channel (or a
|
||||
// DM-targeted handshake) is the proper transport for this and is tracked
|
||||
// separately. See encode_identity_broadcast / parse_identity_broadcast.
|
||||
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
|
||||
|
||||
// Fetch existing contacts from the device
|
||||
refresh_contacts(&mut device, state).await;
|
||||
@@ -507,11 +615,9 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// Re-broadcast archipelago identity so peers that joined since
|
||||
// startup (or missed it) can bind our DID/keys.
|
||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// (Identity re-broadcast on the public channel intentionally
|
||||
// removed — see the note at session startup. It spammed the
|
||||
// shared channel every advert tick.)
|
||||
refresh_contacts(&mut device, state).await;
|
||||
}
|
||||
|
||||
@@ -562,6 +668,18 @@ async fn handle_send_command(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
} => {
|
||||
send_plain_native_text(
|
||||
device,
|
||||
&dest_pubkey_prefix,
|
||||
&payload,
|
||||
consecutive_write_failures,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
@@ -615,5 +733,22 @@ async fn handle_send_command(
|
||||
MeshCommand::RefreshContacts => {
|
||||
refresh_contacts(device, state).await;
|
||||
}
|
||||
MeshCommand::RemoveContact { pubkey } => {
|
||||
if let Err(e) = device.remove_contact(&pubkey).await {
|
||||
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
|
||||
} else {
|
||||
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
|
||||
}
|
||||
}
|
||||
MeshCommand::AddContact { pubkey, name } => {
|
||||
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
|
||||
// until a path is learned). last_advert=0 lets the firmware keep its
|
||||
// own advert timestamp.
|
||||
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
|
||||
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
|
||||
} else {
|
||||
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
/// Meshtastic PortNum for admin (config) packets.
|
||||
const ADMIN_APP: u32 = 6;
|
||||
/// AdminMessage.set_owner oneof field number (carries a `User`).
|
||||
const ADMIN_SET_OWNER_FIELD: u64 = 32;
|
||||
/// Meshtastic firmware caps long_name at ~40 bytes and short_name at 4 bytes.
|
||||
const MESHTASTIC_LONG_NAME_MAX: usize = 39;
|
||||
const MESHTASTIC_SHORT_NAME_MAX: usize = 4;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
@@ -42,6 +49,14 @@ pub struct MeshtasticDevice {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
|
||||
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
|
||||
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
|
||||
/// the synthetic node-num-derived routing key that `send_text_msg` relies
|
||||
/// on — we must not overwrite that or unicast routing breaks. This map only
|
||||
/// records which peers are PKC-capable, so we can tell a true end-to-end
|
||||
/// (PKI) DM from a channel-PSK fallback.
|
||||
peer_pubkeys: HashMap<u32, Vec<u8>>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
@@ -68,6 +83,7 @@ impl MeshtasticDevice {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
peer_pubkeys: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -134,8 +150,56 @@ impl MeshtasticDevice {
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename the connected Meshtastic radio to match the node's server name so
|
||||
/// it's findable from external Meshtastic apps (phone/desktop) on the same
|
||||
/// mesh. Previously this only updated the in-memory field and never told the
|
||||
/// device — so the radio kept its firmware-default name ("Meshtastic xxxx").
|
||||
///
|
||||
/// We push an `AdminMessage { set_owner: User { long_name, short_name } }` to
|
||||
/// the locally-connected node (an admin packet addressed to our own
|
||||
/// `node_num`, on the ADMIN_APP port). Local admin over the serial link needs
|
||||
/// no session passkey, so this is the same path the official phone/CLI client
|
||||
/// uses for "set owner".
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
let long_name: String = name.chars().take(MESHTASTIC_LONG_NAME_MAX).collect();
|
||||
let short_name = derive_short_name(name).unwrap_or_else(|| {
|
||||
self.short_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "NODE".to_string())
|
||||
});
|
||||
|
||||
let Some(node_num) = self.node_num else {
|
||||
// No local node number yet (initialize() not completed) — can't
|
||||
// address a local admin packet. Record the intent so advert_name()
|
||||
// still reflects it, but skip the device write.
|
||||
warn!("Meshtastic set_advert_name: node_num unknown, skipping device write");
|
||||
self.long_name = Some(long_name);
|
||||
self.short_name = Some(short_name);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// User { id?(1), long_name(2), short_name(3) }. Echo back the existing id
|
||||
// when known so the firmware keeps the node's stable `!xxxxxxxx` id.
|
||||
let mut user = Vec::new();
|
||||
if let Some(id) = &self.user_id {
|
||||
encode_len_field(1, id.as_bytes(), &mut user);
|
||||
}
|
||||
encode_len_field(2, long_name.as_bytes(), &mut user);
|
||||
encode_len_field(3, short_name.as_bytes(), &mut user);
|
||||
|
||||
// AdminMessage { set_owner(32): User }
|
||||
let mut admin = Vec::new();
|
||||
encode_len_field(ADMIN_SET_OWNER_FIELD, &user, &mut admin);
|
||||
|
||||
// Admin packet to ourselves on the ADMIN_APP port.
|
||||
let packet = encode_mesh_packet(node_num, ADMIN_APP, &admin);
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
.context("Failed to send Meshtastic set_owner admin packet")?;
|
||||
|
||||
info!(node_num, long_name = %long_name, short_name = %short_name, "Set Meshtastic device owner");
|
||||
self.long_name = Some(long_name);
|
||||
self.short_name = Some(short_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -150,6 +214,52 @@ impl MeshtasticDevice {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
|
||||
/// numeric node-id in their first 4 bytes (little-endian, see
|
||||
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
|
||||
/// destination node number. We send a directed MeshPacket (`to` = node num)
|
||||
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
|
||||
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
|
||||
/// as a real DM (only the recipient's client surfaces it) instead of
|
||||
/// polluting the shared primary channel where every node would see it.
|
||||
///
|
||||
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
|
||||
/// synthetic key routed here by mistake), fall back to a channel send so the
|
||||
/// device interface stays uniform and the message still goes out.
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let node_num = u32::from_le_bytes([
|
||||
dest_pubkey_prefix[0],
|
||||
dest_pubkey_prefix[1],
|
||||
dest_pubkey_prefix[2],
|
||||
dest_pubkey_prefix[3],
|
||||
]);
|
||||
if node_num == 0 || node_num == BROADCAST_NUM {
|
||||
return self.send_channel_text(0, msg).await;
|
||||
}
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||
/// device interface stays uniform.
|
||||
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_contact(
|
||||
&mut self,
|
||||
_pubkey: &[u8; 32],
|
||||
_contact_type: u8,
|
||||
_flags: u8,
|
||||
_out_path_len: u8,
|
||||
_name: &str,
|
||||
_last_advert: u32,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
@@ -188,6 +298,19 @@ impl MeshtasticDevice {
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||
/// the shared device interface (which would break meshcore hot-swap).
|
||||
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||
self.peer_pubkeys
|
||||
.get(&node_num)
|
||||
.is_some_and(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
@@ -260,6 +383,15 @@ impl MeshtasticDevice {
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
if let Some(pk) = node.public_key.as_ref() {
|
||||
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
|
||||
debug!(
|
||||
node = node.num,
|
||||
key_len = pk.len(),
|
||||
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
@@ -292,6 +424,18 @@ impl MeshtasticDevice {
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||
// device interface stays identical to meshcore's and the two remain
|
||||
// hot-swappable behind the mesh listener.
|
||||
if let Some(pk) = packet.public_key.as_ref() {
|
||||
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||
}
|
||||
if packet.pki_encrypted {
|
||||
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
@@ -339,6 +483,23 @@ fn encode_want_config() -> Vec<u8> {
|
||||
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
|
||||
}
|
||||
|
||||
/// Derive a Meshtastic short_name (≤4 chars, the label shown on node icons) from
|
||||
/// the human node name: the first few alphanumeric characters, upper-cased.
|
||||
/// Returns `None` when the name has no usable alphanumeric characters.
|
||||
fn derive_short_name(name: &str) -> Option<String> {
|
||||
let short: String = name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.take(MESHTASTIC_SHORT_NAME_MAX)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
if short.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(short)
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
@@ -418,6 +579,7 @@ struct ParsedNode {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
@@ -428,6 +590,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -440,6 +603,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
node.public_key = user.public_key;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
@@ -457,6 +621,7 @@ struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
@@ -465,6 +630,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -473,6 +639,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
// User.public_key (field 8): the peer's Curve25519 key. Its presence
|
||||
// means the radio can PKC-encrypt DMs to this node end-to-end.
|
||||
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -483,18 +652,28 @@ struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||
/// rather than via the shared channel PSK.
|
||||
pki_encrypted: bool,
|
||||
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
let mut pki_encrypted = false;
|
||||
let mut public_key = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +694,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
pki_encrypted,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
/// high half of u32 space to avoid collision. Both the receive path
|
||||
/// (`inject_typed_from_federation`) and the startup pre-seed use this
|
||||
/// formula so they always produce the same id for the same peer.
|
||||
/// Mesh contacts at or above this id are synthetic federation peers (the high
|
||||
/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id
|
||||
/// space, so this bit cleanly distinguishes "arrived over the authenticated
|
||||
/// federation transport" from "heard over the radio".
|
||||
pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000;
|
||||
|
||||
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
|
||||
if bytes.len() < 4 {
|
||||
@@ -55,6 +61,157 @@ pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
0x8000_0000 | (low & 0x7FFF_FFFF)
|
||||
}
|
||||
|
||||
/// Bind radio (LoRa) contacts to their federation twin's archipelago identity.
|
||||
///
|
||||
/// The same physical node commonly appears twice in the peer table: a radio
|
||||
/// contact (low `contact_id`, firmware routing key only, `arch_pubkey_hex ==
|
||||
/// None`) and a federation peer (high `contact_id`, `arch_pubkey_hex` set). The
|
||||
/// radio half carries no archipelago identity because identity adverts are no
|
||||
/// longer broadcast on the public channel (anti-spam), so the `!ai` trust gate
|
||||
/// and envelope signature verification have no key to check a radio asker
|
||||
/// against — a `!ai` from a trusted node over LoRa is therefore denied, and the
|
||||
/// node shows up as two separate contacts.
|
||||
///
|
||||
/// We correlate the two halves by exact, case-insensitive `advert_name` and copy
|
||||
/// the federation peer's `arch_pubkey_hex`/`did`/`x25519` onto the radio peer.
|
||||
/// This only supplies a CANDIDATE identity key; it does NOT bypass
|
||||
/// authentication. A radio envelope must still carry an Ed25519 signature that
|
||||
/// verifies against this bound key (see `handle_typed_envelope_direct`), so a
|
||||
/// meshcore node merely *named* like a trusted node cannot impersonate it — it
|
||||
/// cannot produce the signature. The candidate key comes from the authenticated
|
||||
/// federation handshake (`nodes.json`), never from anything the radio packet
|
||||
/// claims. Names held by more than one federation peer are treated as ambiguous
|
||||
/// and skipped so a duplicate name can't bind the wrong identity.
|
||||
pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, MeshPeer>) {
|
||||
// name (lowercased) -> federation identity; `None` marks an ambiguous name
|
||||
// (seen on more than one federation peer) which we must not bind.
|
||||
type FedIdentity = (String, Option<String>, Option<[u8; 32]>);
|
||||
let mut fed_by_name: std::collections::HashMap<String, Option<FedIdentity>> =
|
||||
std::collections::HashMap::new();
|
||||
for p in peers.values() {
|
||||
if p.contact_id < FEDERATION_CONTACT_ID_BASE {
|
||||
continue;
|
||||
}
|
||||
let Some(arch) = p.arch_pubkey_hex.clone() else {
|
||||
continue;
|
||||
};
|
||||
let name = p.advert_name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fed_by_name
|
||||
.entry(name)
|
||||
.and_modify(|e| *e = None) // a second federation peer with this name → ambiguous
|
||||
.or_insert(Some((arch, p.did.clone(), p.x25519_pubkey)));
|
||||
}
|
||||
if fed_by_name.is_empty() {
|
||||
return;
|
||||
}
|
||||
for p in peers.values_mut() {
|
||||
if p.contact_id >= FEDERATION_CONTACT_ID_BASE || p.arch_pubkey_hex.is_some() {
|
||||
continue;
|
||||
}
|
||||
let name = p.advert_name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((arch, did, x25519))) = fed_by_name.get(&name) {
|
||||
p.arch_pubkey_hex = Some(arch.clone());
|
||||
if p.did.is_none() {
|
||||
p.did = did.clone();
|
||||
}
|
||||
if p.x25519_pubkey.is_none() {
|
||||
p.x25519_pubkey = *x25519;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One logical contact after collapsing cross-transport twins (see
|
||||
/// [`group_peer_twins`]). A node reachable both over LoRa and over federation
|
||||
/// has two `MeshPeer` rows (different `contact_id`s) but is one conversation.
|
||||
pub(crate) struct PeerGroup {
|
||||
/// The peer row the UI should address. The radio twin when one exists (so
|
||||
/// `send_typed_wire` stays mesh-first — LoRa if reachable, else federation
|
||||
/// via the bound arch key), otherwise the federation row. Gap-healed so its
|
||||
/// name / `arch_pubkey_hex` / `did` are populated from whichever twin had
|
||||
/// them, and `reachable` is the OR across the group.
|
||||
pub canonical: MeshPeer,
|
||||
/// Every `contact_id` in the group. The conversation's messages are the
|
||||
/// union of those keyed by any of these ids — federation-injected messages
|
||||
/// land on the federation twin's id, radio messages on the radio twin's.
|
||||
pub contact_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Collapse cross-transport twin peers into one conversation per identity.
|
||||
///
|
||||
/// The same node commonly appears twice in the peer table: a radio twin (low
|
||||
/// `contact_id`, firmware routing key) and a federation twin (high
|
||||
/// `contact_id`, archipelago key), correlated by [`bind_federation_twins`]
|
||||
/// which copies `arch_pubkey_hex` onto the radio twin but leaves both rows.
|
||||
/// Messages are keyed by `peer_contact_id`, so they split across the two ids:
|
||||
/// a federation-injected message sits on the federation row while the user may
|
||||
/// open the radio row and see an empty thread (the `.120`→`.89` symptom).
|
||||
///
|
||||
/// Group peers by `arch_pubkey_hex` when set, else treat each peer as its own
|
||||
/// singleton group keyed by `contact_id`. Grouping is done ONLY here at surface
|
||||
/// time — never re-keyed at bind time — so outbound routing keeps the distinct
|
||||
/// per-twin `contact_id`s and stays mesh-first. First-seen order is preserved
|
||||
/// for stable downstream sorting.
|
||||
pub(crate) fn group_peer_twins(peers: &[MeshPeer]) -> Vec<PeerGroup> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut groups: std::collections::HashMap<String, Vec<MeshPeer>> =
|
||||
std::collections::HashMap::new();
|
||||
for p in peers {
|
||||
let key = match p.arch_pubkey_hex.as_deref() {
|
||||
Some(arch) if !arch.is_empty() => format!("arch:{}", arch.to_ascii_lowercase()),
|
||||
_ => format!("cid:{}", p.contact_id),
|
||||
};
|
||||
if !groups.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
groups.entry(key).or_default().push(p.clone());
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(order.len());
|
||||
for key in order {
|
||||
let members = match groups.remove(&key) {
|
||||
Some(m) if !m.is_empty() => m,
|
||||
_ => continue,
|
||||
};
|
||||
let contact_ids: Vec<u32> = members.iter().map(|m| m.contact_id).collect();
|
||||
// Canonical = the radio twin (lowest id below the federation base) when
|
||||
// one exists, else the lowest id overall (a federation-only peer).
|
||||
let canonical_src = members
|
||||
.iter()
|
||||
.filter(|m| m.contact_id < FEDERATION_CONTACT_ID_BASE)
|
||||
.min_by_key(|m| m.contact_id)
|
||||
.or_else(|| members.iter().min_by_key(|m| m.contact_id))
|
||||
.expect("non-empty members");
|
||||
let mut canonical = canonical_src.clone();
|
||||
// Heal gaps from the twin: a radio row may lack the advert name, arch
|
||||
// identity, or did that only the federation row carries.
|
||||
if canonical.advert_name.trim().is_empty() {
|
||||
if let Some(named) = members.iter().find(|m| !m.advert_name.trim().is_empty()) {
|
||||
canonical.advert_name = named.advert_name.clone();
|
||||
}
|
||||
}
|
||||
if canonical.arch_pubkey_hex.is_none() {
|
||||
canonical.arch_pubkey_hex = members.iter().find_map(|m| m.arch_pubkey_hex.clone());
|
||||
}
|
||||
if canonical.did.is_none() {
|
||||
canonical.did = members.iter().find_map(|m| m.did.clone());
|
||||
}
|
||||
// Reachable if ANY twin is reachable (radio path or off-radio federation).
|
||||
canonical.reachable = members.iter().any(|m| m.reachable);
|
||||
out.push(PeerGroup {
|
||||
canonical,
|
||||
contact_ids,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Upsert a mesh peer record representing a federation node so the UI can
|
||||
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
||||
/// Existing entries (same contact_id) are updated in place, preserving any
|
||||
@@ -77,13 +234,24 @@ pub(crate) async fn upsert_federation_peer(
|
||||
advert_name: display_name,
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
// Federation peers are authenticated by the Tor relay upstream; their
|
||||
// archipelago key is known, so bind it as the identity key too.
|
||||
arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
|
||||
rssi: existing.as_ref().and_then(|p| p.rssi),
|
||||
snr: existing.as_ref().and_then(|p| p.snr),
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
|
||||
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
|
||||
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
|
||||
reachable: true,
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||
// inherit this federation peer's archipelago key — so a signed `!ai`/typed
|
||||
// message arriving over LoRa from it authenticates and the duplicate radio
|
||||
// contact resolves to the same identity.
|
||||
bind_federation_twins(&mut peers);
|
||||
drop(peers);
|
||||
state.update_peer_count().await;
|
||||
contact_id
|
||||
@@ -197,6 +365,10 @@ pub struct MeshConfig {
|
||||
/// local GPU) or "ollama" (a local model on this node).
|
||||
#[serde(default = "default_assistant_backend")]
|
||||
pub assistant_backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai` even
|
||||
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
|
||||
#[serde(default)]
|
||||
pub assistant_allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_assistant_backend() -> String {
|
||||
@@ -224,6 +396,7 @@ impl Default for MeshConfig {
|
||||
assistant_model: None,
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,6 +574,7 @@ impl MeshService {
|
||||
model: config.assistant_model.clone(),
|
||||
trusted_only: config.assistant_trusted_only,
|
||||
backend: config.assistant_backend.clone(),
|
||||
allowed_contacts: config.assistant_allowed_contacts.clone(),
|
||||
},
|
||||
data_dir.to_path_buf(),
|
||||
);
|
||||
@@ -578,6 +752,7 @@ impl MeshService {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await; // skip first
|
||||
let mut last_announced_height: u64 = 0;
|
||||
let mut last_announce_at: Option<std::time::Instant> = None;
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
@@ -595,6 +770,18 @@ impl MeshService {
|
||||
// Poll Bitcoin Core for latest block
|
||||
match bitcoin_rpc_getblockcount(&client).await {
|
||||
Ok(height) if height > last_announced_height => {
|
||||
// Advance the tip baseline immediately so a fast Bitcoin
|
||||
// catch-up (a new block every poll) doesn't re-fire each tick.
|
||||
last_announced_height = height;
|
||||
// Throttle: at most one announcement per ~9 min. Real ~10 min
|
||||
// blocks still propagate, but a rapid catch-up can no longer
|
||||
// flood the shared LoRa channel.
|
||||
if last_announce_at
|
||||
.map(|t| t.elapsed() < Duration::from_secs(540))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
|
||||
// Store in cache
|
||||
let payload = message_types::BlockHeaderPayload {
|
||||
@@ -640,30 +827,15 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any peer if no Archy nodes found
|
||||
if sent == 0 {
|
||||
for peer in peers.values() {
|
||||
if sent >= max_peers { break; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
let _ = bha_state.send_cmd(
|
||||
listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire.clone(),
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE: intentionally NO fallback to arbitrary
|
||||
// peers. Block headers go ONLY to known Archy
|
||||
// (federated) nodes — never to random meshcore
|
||||
// devices on the shared public channel.
|
||||
drop(peers);
|
||||
last_announced_height = height;
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
if sent > 0 {
|
||||
last_announce_at = Some(std::time::Instant::now());
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to build block announcement: {}", e),
|
||||
}
|
||||
@@ -917,7 +1089,25 @@ impl MeshService {
|
||||
// over Tor; otherwise the send falls through to LoRa.
|
||||
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
|
||||
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
|
||||
if is_federation_synthetic || exceeds_lora {
|
||||
// Mesh-preferred routing with a federation fallback. A normal radio
|
||||
// contact is delivered over LoRa (preferred — free, local, no internet).
|
||||
// But if that contact is the same node as a federated peer — we know its
|
||||
// archipelago identity (`arch_pubkey_hex`) → onion — AND it is NOT
|
||||
// currently reachable over the radio (out of LoRa range, e.g. a peer on
|
||||
// another continent), route the message over the federation transport
|
||||
// (FIPS→Tor) instead of handing it to a radio that physically cannot
|
||||
// deliver it. Reachable radio peers stay on the mesh; oversized
|
||||
// envelopes (file shares etc.) always take the federation path.
|
||||
let radio_federated_unreachable = !is_federation_synthetic
|
||||
&& !exceeds_lora
|
||||
&& {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if is_federation_synthetic || exceeds_lora || radio_federated_unreachable {
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
// but fall back to federation storage for federation-synthetic ids
|
||||
// that were never seeded into `state.peers` — e.g. a radio-less
|
||||
@@ -926,9 +1116,15 @@ impl MeshService {
|
||||
// even though we know its onion from nodes.json.
|
||||
let from_table = {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
|
||||
peers.get(&contact_id).map(|p| {
|
||||
// Resolve via the archipelago IDENTITY key (not the firmware
|
||||
// routing key) — that's what matches the peer's onion entry
|
||||
// in nodes.json for the federation lookup below.
|
||||
(
|
||||
p.arch_pubkey_hex.clone().or_else(|| p.pubkey_hex.clone()),
|
||||
p.did.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let (peer_pubkey, peer_did) = match from_table {
|
||||
Some(v) => v,
|
||||
@@ -1062,7 +1258,14 @@ impl MeshService {
|
||||
"/archipelago/mesh-typed",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
// Fast-fail a FIPS path the peer isn't reachable on (the common case
|
||||
// for remote/Tailscale peers that share no FIPS overlay with us) so
|
||||
// the Tor fallback delivers the message in ~3-5s instead of the send
|
||||
// hanging on FIPS. FIPS-reachable peers connect in <1s and still use
|
||||
// it; only an unreachable FIPS path is short-circuited. Matches the
|
||||
// federation-sync fix. 8s ≈ the FIPS connect_timeout headroom.
|
||||
.fips_timeout(std::time::Duration::from_secs(8));
|
||||
match req.send_json(&body).await {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered");
|
||||
@@ -1267,13 +1470,57 @@ impl MeshService {
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
|
||||
let seq = self.state.next_send_seq(contact_id).await;
|
||||
// Stock (non-archipelago) radio contacts — e.g. a phone running the
|
||||
// MeshCore app — can't decode our typed envelope and would render it as
|
||||
// garbled bytes. Send them the raw text as a plain native DM instead.
|
||||
// Archipelago peers still get the typed envelope (seq/reply/reaction
|
||||
// addressing + encryption).
|
||||
if !self.is_archy_peer(contact_id).await {
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
self.state
|
||||
.send_cmd(listener::MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: dest_prefix,
|
||||
payload: text.as_bytes().to_vec(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
return Ok(self
|
||||
.record_sent_typed(contact_id, "text", text, None, seq)
|
||||
.await);
|
||||
}
|
||||
// Sign the envelope with our archipelago identity key so the receiver
|
||||
// can authenticate us over LoRa (it verifies against our bound
|
||||
// `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a
|
||||
// trusted node pass the receiver's `trusted_only` gate over the radio —
|
||||
// an unsigned radio packet can never authenticate. The signature is
|
||||
// optional on the wire and ignored by peers that don't know our key, so
|
||||
// it stays backward compatible. (Federation/Tor sends already sign in
|
||||
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
|
||||
// — seq is not covered by the signature.
|
||||
let envelope =
|
||||
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
|
||||
.with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
|
||||
/// Federation-synthetic ids are always archy; radio contacts count as archy
|
||||
/// only once we've learned their archipelago identity (DID or x25519 key,
|
||||
/// from federation seeding or an identity exchange). Stock clients have
|
||||
/// neither, so we send them plain text rather than typed envelopes.
|
||||
async fn is_archy_peer(&self, contact_id: u32) -> bool {
|
||||
if contact_id & 0x8000_0000 != 0 {
|
||||
return true;
|
||||
}
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| p.did.is_some() || p.x25519_pubkey.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Record a Sent MeshMessage for a typed envelope that has already been
|
||||
/// transmitted by the caller. Used by the RPC layer after sending
|
||||
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
||||
@@ -1392,6 +1639,12 @@ impl MeshService {
|
||||
self.state.assistant.read().await.clone()
|
||||
}
|
||||
|
||||
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
|
||||
/// them. Cleared implicitly as new denials rotate older ones out.
|
||||
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
|
||||
self.state.assist_denied.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
/// persist them to the mesh config. `model: Some(None)` clears the override
|
||||
/// (falls back to the built-in default); `None` leaves a field unchanged.
|
||||
@@ -1401,6 +1654,7 @@ impl MeshService {
|
||||
model: Option<Option<String>>,
|
||||
trusted_only: Option<bool>,
|
||||
backend: Option<String>,
|
||||
allowed_contacts: Option<Vec<String>>,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let mut a = self.state.assistant.write().await;
|
||||
@@ -1416,6 +1670,9 @@ impl MeshService {
|
||||
if let Some(b) = backend {
|
||||
a.backend = b;
|
||||
}
|
||||
if let Some(list) = allowed_contacts {
|
||||
a.allowed_contacts = list;
|
||||
}
|
||||
}
|
||||
// Persist by updating the on-disk config (the in-memory `self.config`
|
||||
// snapshot stays as-is; the live `state.assistant` is the runtime
|
||||
@@ -1427,6 +1684,7 @@ impl MeshService {
|
||||
cfg.assistant_model = a.model.clone();
|
||||
cfg.assistant_trusted_only = a.trusted_only;
|
||||
cfg.assistant_backend = a.backend.clone();
|
||||
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
|
||||
}
|
||||
save_config(&self.data_dir, &cfg).await?;
|
||||
Ok(())
|
||||
@@ -1587,6 +1845,57 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id,
|
||||
advert_name: name.to_string(),
|
||||
did: None,
|
||||
pubkey_hex: Some(format!("fw{contact_id}")),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_peer_twins_collapses_radio_and_federation() {
|
||||
let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false);
|
||||
let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true);
|
||||
let groups = group_peer_twins(&[radio, fed]);
|
||||
assert_eq!(groups.len(), 1, "twins must collapse to one conversation");
|
||||
let g = &groups[0];
|
||||
// Canonical = the radio twin (mesh-first send), but reachability is the
|
||||
// OR across twins (federation is reachable off-radio).
|
||||
assert_eq!(g.canonical.contact_id, 42);
|
||||
assert!(g.canonical.reachable);
|
||||
// Both ids retained so messages can be unioned across them.
|
||||
assert!(g.contact_ids.contains(&42) && g.contact_ids.contains(&0x8000_0001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_peer_twins_keeps_distinct_identities_and_unbound_radio() {
|
||||
// Two different identities + one radio peer that was never bound to a
|
||||
// federation twin (arch = None) → three separate conversations.
|
||||
let a = mk_peer(1, "Alice", Some("aa"), false);
|
||||
let b = mk_peer(2, "Bob", Some("bb"), true);
|
||||
let lonely = mk_peer(3, "Carol-radio", None, false);
|
||||
let groups = group_peer_twins(&[a, b, lonely]);
|
||||
assert_eq!(groups.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_peer_twins_federation_only_uses_federation_id() {
|
||||
let fed = mk_peer(0x8000_00ff, "Arch Dev", Some("dead"), true);
|
||||
let groups = group_peer_twins(&[fed]);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].canonical.contact_id, 0x8000_00ff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mesh_config_default() {
|
||||
let config = MeshConfig::default();
|
||||
|
||||
@@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
||||
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
||||
/// frames to such contacts.
|
||||
pub const CMD_RESET_PATH: u8 = 0x0D;
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
|
||||
/// table. 144-byte frame (see `build_add_contact`).
|
||||
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
|
||||
/// firmware's persistent table (used by clear-all so wiped contacts actually
|
||||
/// go away and only return when they re-advertise).
|
||||
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
|
||||
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
||||
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
||||
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
||||
@@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
|
||||
/// the firmware's persistent contact table.
|
||||
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
let mut data = vec![CMD_REMOVE_CONTACT];
|
||||
data.extend_from_slice(pubkey);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
|
||||
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
|
||||
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
|
||||
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
|
||||
/// advert on its side too, so an empty name still resolves on get-contacts).
|
||||
pub fn build_add_contact(
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(144);
|
||||
data.push(CMD_ADD_UPDATE_CONTACT);
|
||||
data.extend_from_slice(pubkey); // 32
|
||||
data.push(contact_type); // 1
|
||||
data.push(flags); // 1
|
||||
data.push(out_path_len); // 1
|
||||
data.extend_from_slice(&[0u8; 64]); // out_path (64)
|
||||
let mut name_buf = [0u8; 32];
|
||||
let nb = name.as_bytes();
|
||||
let n = nb.len().min(32);
|
||||
name_buf[..n].copy_from_slice(&nb[..n]);
|
||||
data.extend_from_slice(&name_buf); // name (32)
|
||||
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
||||
pub fn build_sync_next_message() -> Vec<u8> {
|
||||
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
||||
|
||||
@@ -206,6 +206,24 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
|
||||
/// addressed by the first 6 bytes of its public key. Unlike the
|
||||
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
|
||||
/// the public channel, and a stock meshcore client receives it as a normal
|
||||
/// DM. The contact must already exist in the firmware table (with a path).
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
|
||||
self.send_raw(&frame_data).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Direct text send failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the stored routing path for a contact so the firmware flood-
|
||||
/// routes future messages instead of dropping them when path_len=0.
|
||||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
@@ -217,6 +235,47 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a contact from the firmware's persistent contact table.
|
||||
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
self.send_raw(&protocol::build_remove_contact(pubkey))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Remove contact failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
|
||||
/// Used to import a heard advert so it shows up as a contact immediately.
|
||||
pub async fn add_contact(
|
||||
&mut self,
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_add_contact(
|
||||
pubkey,
|
||||
contact_type,
|
||||
flags,
|
||||
out_path_len,
|
||||
name,
|
||||
last_advert,
|
||||
))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Add contact failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of known contacts from the device.
|
||||
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
||||
|
||||
@@ -32,8 +32,18 @@ pub struct MeshPeer {
|
||||
pub advert_name: String,
|
||||
/// Archipelago DID (did:key:z...) if identity was received.
|
||||
pub did: Option<String>,
|
||||
/// Ed25519 public key hex if identity was received.
|
||||
/// Routing key hex. For a radio (meshcore) peer this is the firmware
|
||||
/// contact public key used to address outbound DMs; for a federation-
|
||||
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
|
||||
/// for authentication — see `arch_pubkey_hex`.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// Verified archipelago ed25519 identity key hex, bound from a signed
|
||||
/// identity advert (`handle_identity_received`) or federation seeding.
|
||||
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
|
||||
/// with the firmware routing key, so it stays stable for the `!ai` auth
|
||||
/// gate, envelope signature verification, and federation-trust matching.
|
||||
#[serde(default)]
|
||||
pub arch_pubkey_hex: Option<String>,
|
||||
/// X25519 public key (32 bytes) for key agreement.
|
||||
#[serde(skip)]
|
||||
pub x25519_pubkey: Option<[u8; 32]>,
|
||||
@@ -45,6 +55,28 @@ pub struct MeshPeer {
|
||||
pub last_heard: String,
|
||||
/// Number of hops to reach this peer.
|
||||
pub hops: u8,
|
||||
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
|
||||
/// 0 if unknown. Used to gauge reachability/recency in the UI.
|
||||
#[serde(default)]
|
||||
pub last_advert: u32,
|
||||
/// Best-effort "currently reachable" flag: the radio has a route to this
|
||||
/// contact (or it's a federation/identity peer reachable off-radio). A
|
||||
/// contact with no path and no recent advert is shown as unreachable.
|
||||
#[serde(default)]
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
|
||||
/// envelope signature verification): the verified archipelago identity key
|
||||
/// if one is bound, otherwise the routing key. Never use the firmware
|
||||
/// routing key for auth when an archipelago identity is known — a radio
|
||||
/// peer's firmware key won't match its `nodes.json` archipelago key.
|
||||
pub fn identity_pubkey_hex(&self) -> Option<&str> {
|
||||
self.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.or(self.pubkey_hex.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of a mesh message.
|
||||
@@ -172,4 +204,61 @@ pub enum MeshEvent {
|
||||
to_contact_id: u32,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
|
||||
/// the 1:1 thread via the transport-aware `MeshService::send_message`
|
||||
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
|
||||
/// emits this because it can't route over federation itself — the signing
|
||||
/// key and Tor client live on MeshService. Consumed at the server layer.
|
||||
AssistChatReply {
|
||||
contact_id: u32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id: 1,
|
||||
advert_name: "Test".into(),
|
||||
did: None,
|
||||
pubkey_hex: routing.map(|s| s.to_string()),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
|
||||
// A radio peer that sent an identity advert: routing key is the firmware
|
||||
// contact key, but auth must use the bound archipelago key.
|
||||
let p = peer(Some("archkey"), Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_falls_back_to_routing_key_when_no_advert() {
|
||||
// A plain peer with no archipelago identity bound: fall back to whatever
|
||||
// key we have (federation peers carry the arch key in pubkey_hex).
|
||||
let p = peer(None, Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
|
||||
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_style_routing_update_does_not_change_identity() {
|
||||
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
|
||||
// firmware key while arch_pubkey_hex (identity) is preserved.
|
||||
let mut p = peer(Some("archkey"), Some("firmware-old"));
|
||||
p.pubkey_hex = Some("firmware-new".into());
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,47 @@ impl Server {
|
||||
.rpc_handler()
|
||||
.set_mesh_service(mesh_service)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (#50): deliver `!ai`-in-chat answers via
|
||||
// the transport-aware send path. The listener can't route
|
||||
// over federation itself (send_message needs the signing key
|
||||
// + Tor client on MeshService), so it emits AssistChatReply
|
||||
// and we fulfil it here through the shared MeshService —
|
||||
// which POSTs over Tor for federation askers and falls back
|
||||
// to LoRa for radio askers, recording the Sent bubble.
|
||||
{
|
||||
let mesh_arc = api_handler.rpc_handler().mesh_service_arc();
|
||||
let mut reply_rx = {
|
||||
let guard = mesh_arc.read().await;
|
||||
guard.as_ref().map(|svc| svc.state().event_tx.subscribe())
|
||||
};
|
||||
if let Some(mut rx) = reply_rx.take() {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(crate::mesh::MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text,
|
||||
}) => {
|
||||
let guard = mesh_arc.read().await;
|
||||
if let Some(svc) = guard.as_ref() {
|
||||
if let Err(e) =
|
||||
svc.send_message(contact_id, &text).await
|
||||
{
|
||||
warn!("AI chat reply send failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
_,
|
||||
)) => continue,
|
||||
Err(_) => break, // sender dropped → mesh stopped
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
info!("📡 Mesh service initialized");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -387,6 +428,99 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic federation auto-sync. Pulls every federated peer's state on a
|
||||
// timer so renamed nodes and roster changes propagate WITHOUT a manual
|
||||
// "Sync" click. Each sync now fast-fails a dead FIPS path and falls back
|
||||
// to Tor (~3-5s), so a full pass over a handful of peers is quick.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
let state = state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
// Delay the first pass so Tor/onion publishing settles after boot.
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(90));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let nodes = match crate::federation::load_nodes(&data_dir).await {
|
||||
Ok(n) if !n.is_empty() => n,
|
||||
_ => continue,
|
||||
};
|
||||
let (snap, _) = state.get_snapshot().await;
|
||||
let local_did =
|
||||
match crate::identity::did_key_from_pubkey_hex(&snap.server_info.pubkey) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Our own identity, for re-asserting membership to any peer
|
||||
// that doesn't list us back (asymmetry self-heal, below).
|
||||
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = snap.server_info.pubkey.clone();
|
||||
let local_name = snap.server_info.name.clone();
|
||||
let local_fips_npub =
|
||||
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||
let mut ok = 0usize;
|
||||
let mut healed = 0usize;
|
||||
for node in &nodes {
|
||||
if node.trust_level == crate::federation::TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
match crate::federation::sync_with_peer(&data_dir, node, &local_did, |b| {
|
||||
node_identity.sign(b)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
ok += 1;
|
||||
// Asymmetry self-heal: if this peer's exported
|
||||
// trusted list doesn't include us, our original
|
||||
// peer-joined never landed (e.g. it was sent
|
||||
// before the reliable-notify fix, or the peer was
|
||||
// down). Re-assert membership over the now
|
||||
// FIPS-fast-failing/Tor path so they add us back.
|
||||
// Without this, a node that joined everyone stays
|
||||
// invisible to the whole fleet until a manual
|
||||
// re-add (the "X250-EXP missing everywhere" case).
|
||||
let they_list_us = state
|
||||
.federated_peers
|
||||
.iter()
|
||||
.any(|h| h.did == local_did);
|
||||
if !they_list_us && !local_onion.is_empty() {
|
||||
crate::federation::notify_join(
|
||||
&node.onion,
|
||||
node.fips_npub.as_deref(),
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|b| node_identity.sign(b),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
healed += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)")
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
synced = ok,
|
||||
reasserted = healed,
|
||||
total = nodes.len(),
|
||||
"federation auto-sync pass complete"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize container scanner — discovers installed apps from Podman/Docker
|
||||
{
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
|
||||
@@ -42,6 +42,16 @@ pub struct EcashTransaction {
|
||||
/// Peer identifier (DID, pubkey, or onion) if applicable.
|
||||
#[serde(default)]
|
||||
pub peer: String,
|
||||
/// Which ecash system this entry belongs to: "cashu" or "fedimint". Lets the
|
||||
/// unified history/UI label each transaction. Defaults to "cashu" so legacy
|
||||
/// stored entries (written before dual-ecash) read back correctly.
|
||||
#[serde(default = "default_tx_kind")]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Default `kind` for transactions persisted before the dual-ecash split.
|
||||
pub fn default_tx_kind() -> String {
|
||||
"cashu".to_string()
|
||||
}
|
||||
|
||||
/// A stored proof with metadata.
|
||||
@@ -190,6 +200,7 @@ impl WalletState {
|
||||
description: description.to_string(),
|
||||
mint_url: mint_url.to_string(),
|
||||
peer: peer.to_string(),
|
||||
kind: "cashu".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1107,6 +1118,24 @@ pub async fn verify_and_receive_payment(
|
||||
return Ok(received);
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
// rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/
|
||||
// cashuB, or the legacy form handled above), so anything else is treated as
|
||||
// Fedimint notes and redeemed into a joined federation. reissue_into_any
|
||||
// verifies the notes are unspent and credits this node's wallet.
|
||||
if !token_str.starts_with("cashu") {
|
||||
let (received, _fed_id) =
|
||||
crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?;
|
||||
if received < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
received,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
return Ok(received);
|
||||
}
|
||||
|
||||
// Parse and validate cashuA token
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
@@ -1175,8 +1204,13 @@ pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Default mint URL (local Fedimint).
|
||||
/// Default Cashu mint. Minibits is a well-known public Cashu mint — note this
|
||||
/// is a CASHU mint, distinct from the local Fedimint guardian (:8175), which is
|
||||
/// a separate ecash protocol managed under the Fedimint Federations tab. The
|
||||
/// old default pointed at :8175, which incorrectly surfaced the Fedimint URL in
|
||||
/// the Cashu mints list.
|
||||
fn default_mint_url() -> String {
|
||||
"http://127.0.0.1:8175".to_string()
|
||||
"https://mint.minibits.cash/Bitcoin".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1359,11 +1393,11 @@ mod tests {
|
||||
async fn test_save_and_load_wallet_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut wallet = WalletState {
|
||||
mint_url: "http://127.0.0.1:8175".into(),
|
||||
mint_url: "https://mint.minibits.cash/Bitcoin".into(),
|
||||
..Default::default()
|
||||
};
|
||||
wallet.add_proofs(
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
vec![Proof {
|
||||
amount: 42,
|
||||
id: "ks1".into(),
|
||||
@@ -1375,7 +1409,7 @@ mod tests {
|
||||
TransactionType::Mint,
|
||||
42,
|
||||
"Test mint",
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
"",
|
||||
);
|
||||
|
||||
@@ -1491,6 +1525,7 @@ mod tests {
|
||||
description: "test".into(),
|
||||
mint_url: String::new(),
|
||||
peer: String::new(),
|
||||
kind: default_tx_kind(),
|
||||
};
|
||||
let json = serde_json::to_string(&tx).unwrap();
|
||||
assert!(json.contains("\"streamingpayment\""));
|
||||
@@ -1498,7 +1533,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_mint_url() {
|
||||
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
|
||||
assert_eq!(default_mint_url(), "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1517,9 +1552,11 @@ mod tests {
|
||||
.await
|
||||
.unwrap());
|
||||
// Trailing slash on the home URL still matches.
|
||||
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(
|
||||
is_mint_trusted(tmp.path(), "https://mint.minibits.cash/Bitcoin/")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1553,7 +1590,7 @@ mod tests {
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"http://127.0.0.1:8175/",
|
||||
"https://mint.minibits.cash/Bitcoin/",
|
||||
100,
|
||||
10,
|
||||
)
|
||||
|
||||
@@ -49,6 +49,13 @@ pub struct FederationRegistry {
|
||||
|
||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
||||
|
||||
/// Shared HTTP-Basic password between the fmcd container and this bridge. The
|
||||
/// fedimint-clientd manifest generates it via `generated_secrets: [fmcd-password]`
|
||||
/// and injects it through `secret_env`; the bridge reads the same file in
|
||||
/// `from_node`. (Generation lives in `container::secrets`, not here — it's a
|
||||
/// generic, manifest-declared concern, not fedimint-specific.)
|
||||
const FMCD_PASSWORD_SECRET: &str = "fmcd-password";
|
||||
|
||||
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||
let path = data_dir.join(REGISTRY_FILE);
|
||||
if !path.exists() {
|
||||
@@ -72,6 +79,62 @@ pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Local Fedimint transaction log. fmcd has no per-node history API, so we
|
||||
/// record each redeem/spend ourselves and merge it with the Cashu history in
|
||||
/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere.
|
||||
const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json";
|
||||
|
||||
/// Load the local Fedimint transaction log (newest entries last). Empty on any
|
||||
/// error — history is best-effort and must never block a wallet operation.
|
||||
pub async fn load_fedimint_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
||||
match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a Fedimint transaction to the local log so it appears in unified
|
||||
/// history with meaningful data. Best-effort: write failures are logged, not
|
||||
/// propagated, so they never fail the redeem/spend that produced the funds.
|
||||
pub async fn record_fedimint_tx(
|
||||
data_dir: &Path,
|
||||
tx_type: crate::wallet::ecash::TransactionType,
|
||||
amount_sats: u64,
|
||||
federation_id: &str,
|
||||
description: &str,
|
||||
) {
|
||||
let mut txs = load_fedimint_txs(data_dir).await;
|
||||
txs.push(crate::wallet::ecash::EcashTransaction {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
tx_type,
|
||||
amount_sats,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
description: description.to_string(),
|
||||
// Cashu uses mint_url; for Fedimint we record the federation id in `peer`
|
||||
// so the UI can show which federation the funds moved through.
|
||||
mint_url: String::new(),
|
||||
peer: federation_id.to_string(),
|
||||
kind: "fedimint".to_string(),
|
||||
});
|
||||
// Cap the log so it can't grow unbounded.
|
||||
let len = txs.len();
|
||||
if len > 500 {
|
||||
txs.drain(0..len - 500);
|
||||
}
|
||||
if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await {
|
||||
tracing::warn!("fedimint tx log: could not create wallet dir: {e}");
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string_pretty(&txs) {
|
||||
Ok(content) => {
|
||||
if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await {
|
||||
tracing::warn!("fedimint tx log: write failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently ensure the node has joined the default federation and that it
|
||||
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
|
||||
/// isn't installed/running yet. Joining is idempotent on the clientd side.
|
||||
@@ -80,6 +143,34 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // clientd not configured yet
|
||||
};
|
||||
|
||||
// Fast path: if fmcd already reports a joined federation, do NOT re-issue the
|
||||
// POST /v2/admin/join. That call re-syncs federation config against the
|
||||
// guardians and adds seconds of latency — and ensure_default_federation runs
|
||||
// on every wallet.fedimint-list / spend / reissue, so the join was being paid
|
||||
// on each balance refresh (the "mints take ages to load" report). The cheap
|
||||
// GET /v2/admin/info is enough to confirm membership; just reconcile the local
|
||||
// registry against the live joined set and return.
|
||||
let joined = client.joined_federation_ids().await;
|
||||
if !joined.is_empty() {
|
||||
let mut reg = load_registry(data_dir).await?;
|
||||
let mut changed = false;
|
||||
for id in joined {
|
||||
if !reg.federations.iter().any(|f| f.federation_id == id) {
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id: id,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Cold start only: nothing joined yet, so join the default federation once.
|
||||
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
@@ -95,13 +186,165 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id,
|
||||
name: None,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
|
||||
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
|
||||
/// the seller) and the federation id that minted them. Federations are tried in
|
||||
/// registry order (default first); only one with sufficient balance is used so
|
||||
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
|
||||
/// federation is joined or none has the balance — the caller falls back to (or
|
||||
/// from) the Cashu path.
|
||||
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
|
||||
if amount_sats == 0 {
|
||||
anyhow::bail!("payment amount must be greater than zero");
|
||||
}
|
||||
let _ = ensure_default_federation(data_dir).await;
|
||||
let client = FedimintClient::from_node(data_dir).await?;
|
||||
|
||||
// Same union-of-sources approach as reissue_into_any: the persisted registry
|
||||
// and what fmcd actually reports joined can drift, so consider both.
|
||||
let mut fed_ids: Vec<String> = Vec::new();
|
||||
if let Ok(reg) = load_registry(data_dir).await {
|
||||
for f in reg.federations {
|
||||
if !fed_ids.contains(&f.federation_id) {
|
||||
fed_ids.push(f.federation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in client.joined_federation_ids().await {
|
||||
if !fed_ids.contains(&id) {
|
||||
fed_ids.push(id);
|
||||
}
|
||||
}
|
||||
if fed_ids.is_empty() {
|
||||
anyhow::bail!("No Fedimint federation joined to spend from");
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
for fed_id in &fed_ids {
|
||||
// Skip federations that can't cover the amount so we don't mint a
|
||||
// partial/failed spend and leave dangling reserved notes.
|
||||
match client.federation_balance_sats(fed_id).await {
|
||||
Ok(bal) if bal >= amount_sats => {}
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match client.spend(fed_id, amount_sats).await {
|
||||
Ok(notes) => {
|
||||
record_fedimint_tx(
|
||||
data_dir,
|
||||
crate::wallet::ecash::TransactionType::Send,
|
||||
amount_sats,
|
||||
fed_id,
|
||||
"Sent Fedimint ecash",
|
||||
)
|
||||
.await;
|
||||
return Ok((notes, fed_id.clone()));
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err
|
||||
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
|
||||
.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No joined Fedimint federation has {amount_sats} sats available"
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||
/// per-federation, but a token only validates against the federation that
|
||||
/// minted it, so we try each joined federation (default first) and return the
|
||||
/// first that accepts the notes, along with its id. Errors clearly when the
|
||||
/// fmcd sidecar isn't installed or no federation is joined — the Cashu path is
|
||||
/// handled separately by the caller.
|
||||
pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> {
|
||||
// Make sure at least the default federation is tracked before we try.
|
||||
let _ = ensure_default_federation(data_dir).await;
|
||||
|
||||
let client = FedimintClient::from_node(data_dir).await?;
|
||||
|
||||
// Build the set of federations to try: the locally-persisted registry PLUS
|
||||
// every federation the fmcd sidecar actually reports joined. The two can
|
||||
// drift (a federation joined directly, before tracking, or a registry that
|
||||
// wasn't written), and a note only validates against the federation that
|
||||
// minted it — so we must try EVERY connected federation before giving up,
|
||||
// or a perfectly valid token is wrongly reported as failed.
|
||||
let mut fed_ids: Vec<String> = Vec::new();
|
||||
if let Ok(reg) = load_registry(data_dir).await {
|
||||
for f in reg.federations {
|
||||
if !fed_ids.contains(&f.federation_id) {
|
||||
fed_ids.push(f.federation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in client.joined_federation_ids().await {
|
||||
if !fed_ids.contains(&id) {
|
||||
fed_ids.push(id);
|
||||
}
|
||||
}
|
||||
if fed_ids.is_empty() {
|
||||
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
|
||||
}
|
||||
|
||||
let mut already_redeemed = false;
|
||||
let mut last_err = None;
|
||||
for fed_id in &fed_ids {
|
||||
match client.reissue(fed_id, notes).await {
|
||||
Ok(sats) => {
|
||||
// Record the receive so it appears in unified ecash history.
|
||||
record_fedimint_tx(
|
||||
data_dir,
|
||||
crate::wallet::ecash::TransactionType::Receive,
|
||||
sats,
|
||||
fed_id,
|
||||
"Received Fedimint ecash",
|
||||
)
|
||||
.await;
|
||||
return Ok((sats, fed_id.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_ascii_lowercase();
|
||||
// fmcd reports already-claimed notes as "We already reissued
|
||||
// these notes" (or "already spent"). That means the funds were
|
||||
// already redeemed INTO this node's wallet — they're safe, just
|
||||
// not new — so surface that clearly instead of a raw 500.
|
||||
if msg.contains("already reissued") || msg.contains("already spent") {
|
||||
already_redeemed = true;
|
||||
}
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if already_redeemed {
|
||||
anyhow::bail!(
|
||||
"This ecash was already redeemed into your wallet — the notes are \
|
||||
already claimed, so no new balance was added. Check your Fedimint balance."
|
||||
);
|
||||
}
|
||||
Err(last_err
|
||||
.map(|e| {
|
||||
anyhow::anyhow!(
|
||||
"These notes didn't match any of your {} connected Fedimint \
|
||||
federation(s). You may need to join the federation that issued \
|
||||
them first. (last error: {e})",
|
||||
fed_ids.len()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
|
||||
}
|
||||
|
||||
/// HTTP client for a `fedimint-clientd` instance.
|
||||
pub struct FedimintClient {
|
||||
base_url: String,
|
||||
@@ -135,14 +378,25 @@ impl FedimintClient {
|
||||
let password = match std::env::var("FMCD_PASSWORD") {
|
||||
Ok(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
let secret = data_dir.join("fmcd").join("password");
|
||||
fs::read_to_string(&secret)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd/password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
// The shared secret the fmcd container also reads (manifest
|
||||
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
||||
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
||||
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
||||
let legacy = data_dir.join("fmcd").join("password");
|
||||
let mut found = None;
|
||||
for candidate in [shared, legacy] {
|
||||
if let Ok(s) = fs::read_to_string(&candidate).await {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
found = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd-password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &password)
|
||||
@@ -193,6 +447,20 @@ impl FedimintClient {
|
||||
self.get("/v2/admin/info").await
|
||||
}
|
||||
|
||||
/// Every federation id the fmcd sidecar currently reports joined, read from
|
||||
/// `/v2/admin/info` (the authoritative live set — the locally-persisted
|
||||
/// registry can drift from it). Returns an empty vec on any error so callers
|
||||
/// can fall back to the registry rather than fail outright.
|
||||
pub async fn joined_federation_ids(&self) -> Vec<String> {
|
||||
match self.info().await {
|
||||
Ok(info) => info
|
||||
.as_object()
|
||||
.map(|m| m.keys().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
|
||||
pub async fn join(&self, invite_code: &str) -> Result<String> {
|
||||
let res = self
|
||||
|
||||
@@ -9,8 +9,9 @@ pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
|
||||
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
|
||||
SecretsProvider, SecurityPolicy, Volume,
|
||||
GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks, ManifestError,
|
||||
ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
|
||||
@@ -57,10 +57,88 @@ pub struct AppDefinition {
|
||||
#[serde(default)]
|
||||
pub interfaces: HashMap<String, AppInterface>,
|
||||
|
||||
/// Controlled post-install / pre-start lifecycle hooks. Declarative,
|
||||
/// allowlisted operations run against the app's OWN container — never the
|
||||
/// host. See `docs/manifest-hooks-design.md`.
|
||||
#[serde(default)]
|
||||
pub hooks: LifecycleHooks,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extensions: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
/// Declarative lifecycle hooks for an app. Absent = none (forward-compatible).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LifecycleHooks {
|
||||
/// Run once after a successful install, with the container created + running.
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<HookStep>,
|
||||
/// Run before each start (repair/ownership). Reserved; not yet executed.
|
||||
#[serde(default)]
|
||||
pub pre_start: Vec<HookStep>,
|
||||
}
|
||||
|
||||
/// A single controlled hook operation. Each list item is a one-key map, e.g.
|
||||
/// `- exec: [...]` or `- copy_from_host: { src, dest }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum HookStep {
|
||||
/// Run a command vector INSIDE the app's container (`podman exec`). Never on
|
||||
/// the host; inherits the container's (already dropped) capabilities.
|
||||
Exec { exec: Vec<String> },
|
||||
/// Copy a file from an allowlisted host root into the container. `src` is
|
||||
/// relative to the allowlist (data dir / web-ui) — no absolute paths, no `..`.
|
||||
CopyFromHost {
|
||||
#[serde(rename = "copy_from_host")]
|
||||
copy_from_host: HostCopy,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HostCopy {
|
||||
pub src: String,
|
||||
pub dest: String,
|
||||
}
|
||||
|
||||
impl LifecycleHooks {
|
||||
fn validate(&self) -> Result<(), ManifestError> {
|
||||
for step in self.post_install.iter().chain(self.pre_start.iter()) {
|
||||
step.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HookStep {
|
||||
fn validate(&self) -> Result<(), ManifestError> {
|
||||
match self {
|
||||
HookStep::Exec { exec } => {
|
||||
if exec.is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"hooks: exec must be a non-empty command vector".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
HookStep::CopyFromHost { copy_from_host } => {
|
||||
let s = ©_from_host.src;
|
||||
if s.is_empty() || s.starts_with('/') || s.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"hooks: copy_from_host.src must be a relative allowlisted path \
|
||||
(no leading '/', no '..'), got '{s}'"
|
||||
)));
|
||||
}
|
||||
if copy_from_host.dest.is_empty() || !copy_from_host.dest.starts_with('/') {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"hooks: copy_from_host.dest must be an absolute container path, got '{}'",
|
||||
copy_from_host.dest
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContainerConfig {
|
||||
/// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present.
|
||||
@@ -92,6 +170,17 @@ pub struct ContainerConfig {
|
||||
#[serde(default)]
|
||||
pub network: Option<String>,
|
||||
|
||||
/// Extra DNS aliases the container answers to on its `network`, in addition
|
||||
/// to its own container name (which is always added). Mirrors podman
|
||||
/// `--network-alias`. Used by multi-container stacks whose images reference
|
||||
/// peers by a short baked-in hostname — e.g. indeedhub's frontend nginx
|
||||
/// proxies to `api:4000` / `minio:9000` / `relay:8080`, so the api/minio/relay
|
||||
/// members declare `network_aliases: [api]` / `[minio]` / `[relay]` to keep
|
||||
/// those short names resolvable on the dedicated `indeedhub-net`. Ignored for
|
||||
/// slirp4netns/pasta (podman rejects aliases there).
|
||||
#[serde(default)]
|
||||
pub network_aliases: Vec<String>,
|
||||
|
||||
/// Extra positional arguments appended to the container command
|
||||
/// after the image. Mirrors `SPEC_CUSTOM_ARGS` in
|
||||
/// `scripts/container-specs.sh` (bitcoin-knots prune/dbcache flags,
|
||||
@@ -122,6 +211,18 @@ pub struct ContainerConfig {
|
||||
#[serde(default)]
|
||||
pub secret_env: Vec<SecretEnv>,
|
||||
|
||||
/// Secrets the orchestrator generates on first use when absent, so an app
|
||||
/// installs from its manifest alone — no host provisioning, no per-app Rust.
|
||||
/// Materialised before `secret_env` is resolved, written `0600` and owned by
|
||||
/// the unprivileged (rootless) service user. Idempotent and self-healing: a
|
||||
/// file that already exists and is readable is left untouched; one that is
|
||||
/// present-but-unreadable (e.g. wrongly created `root`-owned) is recreated
|
||||
/// in place via the service-owned secrets dir — no `chown`, no privilege.
|
||||
///
|
||||
/// Example: `- { name: fmcd-password, kind: hex16 }`
|
||||
#[serde(default)]
|
||||
pub generated_secrets: Vec<GeneratedSecret>,
|
||||
|
||||
/// Rootless-mapped UID:GID applied to the container's data directory
|
||||
/// (the `bind`-mounted host path with `target` inside the container's
|
||||
/// data root) before creation. Mirrors `SPEC_DATA_UID`.
|
||||
@@ -151,6 +252,42 @@ pub struct SecretEnv {
|
||||
pub secret_file: String,
|
||||
}
|
||||
|
||||
/// How a [`GeneratedSecret`] is produced. Each kind is deterministic in shape
|
||||
/// (so the orchestrator knows which files to expect) but random in value.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretGenKind {
|
||||
/// 16 random bytes, lowercase hex (32 chars). Service passwords/API tokens.
|
||||
Hex16,
|
||||
/// 32 random bytes, lowercase hex (64 chars). Longer keys/cookies.
|
||||
Hex32,
|
||||
/// A random password and its bcrypt hash. `<name>` holds the bcrypt hash
|
||||
/// (what a server is configured with); the plaintext is stored alongside as
|
||||
/// `<name>.pw` for any client that must authenticate. `secret_env` injects
|
||||
/// whichever file it references.
|
||||
Bcrypt,
|
||||
}
|
||||
|
||||
/// A secret materialised by the orchestrator on demand. See
|
||||
/// [`ContainerConfig::generated_secrets`]. `name` is a bare filename under the
|
||||
/// secrets dir — validated (no `/`, no `..`) at [`AppManifest::validate`] time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GeneratedSecret {
|
||||
pub name: String,
|
||||
pub kind: SecretGenKind,
|
||||
}
|
||||
|
||||
impl GeneratedSecret {
|
||||
/// Every file this secret materialises, in the order they should be written
|
||||
/// (primary first). A consumer references one of these via `secret_env`.
|
||||
pub fn target_files(&self) -> Vec<String> {
|
||||
match self.kind {
|
||||
SecretGenKind::Hex16 | SecretGenKind::Hex32 => vec![self.name.clone()],
|
||||
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_pull_policy() -> String {
|
||||
"if-not-present".to_string()
|
||||
}
|
||||
@@ -413,6 +550,25 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
// network_aliases: each must be a non-empty DNS label (lowercase
|
||||
// alphanumeric + hyphen, no leading/trailing hyphen) so it renders as a
|
||||
// valid podman --network-alias / aardvark-dns name.
|
||||
for (i, alias) in self.app.container.network_aliases.iter().enumerate() {
|
||||
let ok = !alias.is_empty()
|
||||
&& alias.len() <= 63
|
||||
&& alias
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
&& !alias.starts_with('-')
|
||||
&& !alias.ends_with('-');
|
||||
if !ok {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.network_aliases[{i}] '{alias}' must be a non-empty DNS label \
|
||||
(lowercase a-z, 0-9, '-'; no leading/trailing '-')"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// custom_args: no empty strings (would inject literal "" into
|
||||
// the podman command line and confuse downstream parsing).
|
||||
for (i, a) in self.app.container.custom_args.iter().enumerate() {
|
||||
@@ -487,6 +643,28 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
// generated_secrets: bare-filename names, unique across every file the
|
||||
// set materialises (so a Bcrypt's `.pw` sibling can't collide with
|
||||
// another secret). Path-safety mirrors secret_env.
|
||||
{
|
||||
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for (i, g) in self.app.container.generated_secrets.iter().enumerate() {
|
||||
if g.name.is_empty() || g.name.contains('/') || g.name.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.generated_secrets[{}].name must be a bare filename (no '/', no '..'), got '{}'",
|
||||
i, g.name
|
||||
)));
|
||||
}
|
||||
for f in g.target_files() {
|
||||
if !names.insert(f.clone()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.generated_secrets produces duplicate file '{f}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// data_uid: if set, must look like "NNNNN:NNNNN".
|
||||
if let Some(u) = &self.app.container.data_uid {
|
||||
let parts: Vec<&str> = u.split(':').collect();
|
||||
@@ -587,6 +765,10 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle hooks: declarative, allowlisted (no host exec, no absolute /
|
||||
// `..` copy sources). See docs/manifest-hooks-design.md.
|
||||
self.app.hooks.validate()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1002,6 +1184,57 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn hooks_parse_and_validate() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: indeedhub
|
||||
name: IndeedHub
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/indeedhub:1.0.0
|
||||
hooks:
|
||||
post_install:
|
||||
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
assert_eq!(m.app.hooks.post_install.len(), 2);
|
||||
match &m.app.hooks.post_install[0] {
|
||||
HookStep::Exec { exec } => assert_eq!(exec[0], "sed"),
|
||||
_ => panic!("expected exec step"),
|
||||
}
|
||||
match &m.app.hooks.post_install[1] {
|
||||
HookStep::CopyFromHost { copy_from_host } => {
|
||||
assert_eq!(copy_from_host.dest, "/usr/share/nginx/html/nostr-provider.js")
|
||||
}
|
||||
_ => panic!("expected copy_from_host step"),
|
||||
}
|
||||
m.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_reject_absolute_or_traversal_copy_src() {
|
||||
for bad in ["/etc/passwd", "../../etc/shadow", "web-ui/../../etc/x"] {
|
||||
let yaml = format!(
|
||||
"app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n \
|
||||
hooks:\n post_install:\n - copy_from_host:\n src: \"{bad}\"\n dest: \"/x\"\n"
|
||||
);
|
||||
assert!(
|
||||
AppManifest::parse(&yaml).is_err(),
|
||||
"src '{bad}' must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_reject_empty_exec() {
|
||||
let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n";
|
||||
assert!(AppManifest::parse(yaml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_parse() {
|
||||
let yaml = r#"
|
||||
@@ -1459,6 +1692,7 @@ app:
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
network_aliases: vec![],
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![
|
||||
@@ -1476,6 +1710,7 @@ app:
|
||||
},
|
||||
],
|
||||
secret_env: vec![],
|
||||
generated_secrets: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let facts = HostFacts {
|
||||
@@ -1512,6 +1747,7 @@ app:
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
network_aliases: vec![],
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
@@ -1525,6 +1761,7 @@ app:
|
||||
secret_file: "fedimint-gateway-password".to_string(),
|
||||
},
|
||||
],
|
||||
generated_secrets: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
@@ -1553,6 +1790,7 @@ app:
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
network_aliases: vec![],
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
@@ -1560,6 +1798,7 @@ app:
|
||||
key: "BITCOIN_RPC_PASS".to_string(),
|
||||
secret_file: "bitcoin-rpc-password".to_string(),
|
||||
}],
|
||||
generated_secrets: vec![],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
|
||||
@@ -385,11 +385,21 @@ impl PodmanClient {
|
||||
},
|
||||
});
|
||||
if let Some(network) = custom_network {
|
||||
// The container always answers to its own name; manifest
|
||||
// network_aliases add extra short hostnames peers may bake in
|
||||
// (e.g. indeedhub's api/minio/relay). Dedup so a manifest that
|
||||
// redundantly lists its own name doesn't double it.
|
||||
let mut aliases = vec![name.to_string()];
|
||||
for a in &manifest.app.container.network_aliases {
|
||||
if !aliases.iter().any(|x| x == a) {
|
||||
aliases.push(a.clone());
|
||||
}
|
||||
}
|
||||
body.as_object_mut()
|
||||
.expect("container create body is a JSON object")
|
||||
.insert(
|
||||
"networks".to_string(),
|
||||
serde_json::json!({ network: { "aliases": [name] } }),
|
||||
serde_json::json!({ network: { "aliases": aliases } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,11 +422,22 @@ impl PodmanClient {
|
||||
}
|
||||
|
||||
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
self.stop_container_with_grace(name, 10).await
|
||||
}
|
||||
|
||||
/// Stop via libpod honouring a per-app grace (seconds). The HTTP deadline is
|
||||
/// kept above the grace so the post-grace SIGKILL lands before we give up —
|
||||
/// otherwise slow-to-SIGTERM apps (fedimint, bitcoin-core, electrumx…) time
|
||||
/// out at exactly the grace boundary and the stop is reported as failed.
|
||||
pub async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
|
||||
let deadline = std::time::Duration::from_secs(
|
||||
grace_secs + crate::runtime::STOP_GRACE_DEADLINE_BUFFER_SECS,
|
||||
);
|
||||
self.api_request(
|
||||
"POST",
|
||||
&format!("libpod/containers/{}/stop?t=10", name),
|
||||
&format!("libpod/containers/{}/stop?t={}", name, grace_secs),
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
deadline,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
|
||||
@@ -10,6 +10,35 @@ const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const PODMAN_CLI_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
/// Default graceful-stop grace (seconds) when a caller doesn't supply a per-app
|
||||
/// value. Mirrors the historical `podman stop -t 30`.
|
||||
pub const DEFAULT_STOP_GRACE_SECS: u64 = 30;
|
||||
/// Headroom added to a stop grace to form the await/HTTP deadline, so podman's
|
||||
/// post-grace SIGKILL completes before the wrapper times out.
|
||||
pub const STOP_GRACE_DEADLINE_BUFFER_SECS: u64 = 15;
|
||||
|
||||
/// Canonical per-app graceful-stop grace (seconds), keyed by container name.
|
||||
/// Slow-to-SIGTERM apps need far longer than the 30s default: bitcoin-core
|
||||
/// flushes its chainstate, lnd closes channels, electrumx finishes indexing,
|
||||
/// stack DBs checkpoint. Used as the fallback when a manifest doesn't declare
|
||||
/// `stop_grace_secs`. NOTE: the RPC layer's `stop_timeout_secs` mirrors this
|
||||
/// (returns the same values as `&str` for legacy `podman stop -t` call sites) —
|
||||
/// keep the two in sync until that path is retired.
|
||||
pub fn stop_grace_secs_for(container_name: &str) -> u64 {
|
||||
let id = container_name
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(container_name);
|
||||
match id {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 600,
|
||||
"lnd" => 330,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => 300,
|
||||
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres" | "nextcloud-db"
|
||||
| "endurain-db" => 120,
|
||||
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => 60,
|
||||
_ => DEFAULT_STOP_GRACE_SECS,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContainerRuntime: Send + Sync {
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
||||
@@ -21,6 +50,19 @@ pub trait ContainerRuntime: Send + Sync {
|
||||
) -> Result<String>;
|
||||
async fn start_container(&self, name: &str) -> Result<()>;
|
||||
async fn stop_container(&self, name: &str) -> Result<()>;
|
||||
/// Stop a container honouring a per-app graceful-shutdown grace (seconds).
|
||||
///
|
||||
/// Slow-to-SIGTERM apps (bitcoin-core, lnd, electrumx, fedimint, immich…)
|
||||
/// need a longer `podman stop -t` than the default 30s, or `podman stop`
|
||||
/// returns before the container exits and the orchestrator treats the stop
|
||||
/// as failed (the container keeps running). The wrapping deadline is always
|
||||
/// kept strictly greater than `grace_secs` so podman's post-grace SIGKILL
|
||||
/// lands inside the await. The default impl ignores the grace and calls
|
||||
/// `stop_container` — only the real podman runtime honours it.
|
||||
async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
|
||||
let _ = grace_secs;
|
||||
self.stop_container(name).await
|
||||
}
|
||||
async fn remove_container(&self, name: &str) -> Result<()>;
|
||||
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus>;
|
||||
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>>;
|
||||
@@ -122,10 +164,23 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
match self.client.stop_container(name).await {
|
||||
self.stop_container_with_grace(name, DEFAULT_STOP_GRACE_SECS)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
|
||||
match self.client.stop_container_with_grace(name, grace_secs).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["stop", "-t", "30", name]).await?;
|
||||
// CLI fallback. Keep the wrapper deadline strictly above the
|
||||
// `-t` grace so podman's post-grace SIGKILL completes before the
|
||||
// await gives up (otherwise a deadline == grace races the kill
|
||||
// and reports a spurious timeout).
|
||||
let grace = grace_secs.to_string();
|
||||
let deadline = Duration::from_secs(grace_secs + STOP_GRACE_DEADLINE_BUFFER_SECS);
|
||||
let output = self
|
||||
.podman_cli_timeout(&["stop", "-t", &grace, name], deadline)
|
||||
.await?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -841,6 +896,10 @@ impl ContainerRuntime for AutoRuntime {
|
||||
self.runtime.stop_container(name).await
|
||||
}
|
||||
|
||||
async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
|
||||
self.runtime.stop_container_with_grace(name, grace_secs).await
|
||||
}
|
||||
|
||||
async fn remove_container(&self, name: &str) -> Result<()> {
|
||||
self.runtime.remove_container(name).await
|
||||
}
|
||||
|
||||
@@ -737,6 +737,15 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Snapshot age in ms. Prefer the server-computed age_ms (single clock, no
|
||||
// skew). Fall back to the old browser-vs-server subtraction only for an
|
||||
// older backend that doesn't send age_ms. Mixing clocks was why the
|
||||
// "reconnecting…" banner could stick on nodes whose clock drifted.
|
||||
function snapshotAgeMs(status) {
|
||||
if (typeof status.age_ms === 'number') return status.age_ms;
|
||||
return status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function cookieValue(name) {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
@@ -1127,7 +1136,7 @@
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const displayStale = status.stale === true && statusAgeMs > 30000;
|
||||
rpcEl.textContent = displayStale
|
||||
? `Reconnecting on port ${port}`
|
||||
@@ -1143,7 +1152,7 @@
|
||||
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
|
||||
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
|
||||
const previousBlockCount = lastBlockCount;
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
|
||||
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;
|
||||
|
||||
|
||||
@@ -22,6 +22,6 @@ RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 80
|
||||
EXPOSE 18083
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+30
-32
@@ -433,8 +433,12 @@
|
||||
const host = window.location.hostname;
|
||||
|
||||
function getBackendUrl() {
|
||||
// Same-origin by default: the app's own nginx (:18083) proxies these
|
||||
// paths to the archipelago backend, so a relative base ('') avoids
|
||||
// any cross-origin/CORS issues (which broke this on http-only nodes).
|
||||
// ?backend=http://HOST:5678 still overrides for local dev.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('backend') || (window.location.protocol + '//' + window.location.hostname);
|
||||
return params.get('backend') || '';
|
||||
}
|
||||
|
||||
function setSettingsTab(tabId) {
|
||||
@@ -499,42 +503,36 @@
|
||||
async function loadLogs() {
|
||||
const logsContent = document.getElementById('logsContent');
|
||||
const backendUrl = getBackendUrl();
|
||||
if (backendUrl) {
|
||||
logsContent.textContent = 'Loading logs...';
|
||||
try {
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200');
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
|
||||
logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
||||
} catch (e) {
|
||||
logsContent.textContent = 'Could not load logs: ' + e.message;
|
||||
}
|
||||
} else {
|
||||
logsContent.textContent = 'Open this app with ?backend=http://HOST:5678 to load logs from the server.';
|
||||
logsContent.textContent = 'Loading logs...';
|
||||
try {
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
|
||||
logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
||||
} catch (e) {
|
||||
logsContent.textContent = 'Could not load logs: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLiveData() {
|
||||
const backendUrl = getBackendUrl();
|
||||
const data = { channelCount: 0, restReachable: false, grpcReachable: false };
|
||||
if (backendUrl) {
|
||||
try {
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo');
|
||||
if (getinfoRes.ok) {
|
||||
data.getinfo = await getinfoRes.json();
|
||||
data.restReachable = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels');
|
||||
if (chRes.ok) {
|
||||
const ch = await chRes.json();
|
||||
data.channelCount = (ch.channels && ch.channels.length) || 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
data.grpcReachable = data.restReachable;
|
||||
}
|
||||
try {
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo', { credentials: 'include' });
|
||||
if (getinfoRes.ok) {
|
||||
data.getinfo = await getinfoRes.json();
|
||||
data.restReachable = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels', { credentials: 'include' });
|
||||
if (chRes.ok) {
|
||||
const ch = await chRes.json();
|
||||
data.channelCount = (ch.channels && ch.channels.length) || 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
data.grpcReachable = data.restReachable;
|
||||
applyLiveData(data);
|
||||
}
|
||||
|
||||
@@ -629,7 +627,7 @@
|
||||
|
||||
async function fetchConnectInfo() {
|
||||
try {
|
||||
const resp = await fetch(window.location.protocol + '//' + window.location.hostname + '/lnd-connect-info', { credentials: 'include' });
|
||||
const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
if (data.cert_base64url) {
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
# Host-networked: listen on the app's own port directly (NOT 80, which the
|
||||
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
|
||||
listen 18083;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# lnd-connect-info is fetched via absolute URL path,
|
||||
# handled by the host nginx → backend at :5678 directly
|
||||
# Proxy the archipelago backend same-origin so the browser never makes a
|
||||
# cross-origin request (no CORS, no host-nginx route dependency). The app is
|
||||
# served on this node's :18083; cookies are scoped by host (not port), so the
|
||||
# browser already carries the `session` (HttpOnly) and `csrf_token` cookies
|
||||
# set by the main UI. We forward both, plus the X-CSRF-Token header, to the
|
||||
# backend on 127.0.0.1:5678 (reachable because this container is host-networked).
|
||||
#
|
||||
# This mirrors fips-ui / electrs-ui. The old bridge + 18083→80 mapping forced
|
||||
# cross-origin fetches that broke on http-only nodes (blank fields, QR
|
||||
# "failed to fetch").
|
||||
location = /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location /proxy/lnd/ {
|
||||
proxy_pass http://127.0.0.1:5678/proxy/lnd/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /api/container/logs {
|
||||
proxy_pass http://127.0.0.1:5678/api/container/logs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||
#
|
||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
||||
# after any backend restart (podman reassigns the IP). See the script header.
|
||||
ARG BASE=146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
|
||||
FROM ${BASE}
|
||||
|
||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
||||
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||
# nginx -g "daemon off;") are inherited unchanged.
|
||||
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/bin/sh
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
CONF=/etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
# ─── archipelago patch ────────────────────────────────────────────────────
|
||||
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
|
||||
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
|
||||
# start and caches it for the process lifetime. Podman reassigns the backend
|
||||
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
|
||||
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
|
||||
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
|
||||
#
|
||||
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
|
||||
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
|
||||
# location→URI rewriting, each block is rewritten to preserve its original
|
||||
# path mapping exactly:
|
||||
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
|
||||
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
|
||||
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
|
||||
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
|
||||
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
|
||||
# Resolver address: podman's aardvark-dns answers on the network gateway
|
||||
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
|
||||
# works on any podman network/subnet (and still falls back for Docker).
|
||||
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
|
||||
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
|
||||
|
||||
if ! grep -q 'set \$mp_backend' "$CONF"; then
|
||||
awk -v res_addr="$ARCHY_RESOLVER" '
|
||||
BEGIN { res = 0 }
|
||||
/^[[:space:]]*location / && res == 0 {
|
||||
print "\tresolver " res_addr " valid=10s ipv6=off;"
|
||||
res = 1
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
|
||||
fi
|
||||
# ─── end archipelago patch ────────────────────────────────────────────────
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
|
||||
export LIGHTNING=true
|
||||
fi
|
||||
|
||||
# Runtime overrides - read env vars defined in docker compose
|
||||
|
||||
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
|
||||
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
|
||||
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
|
||||
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
|
||||
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
|
||||
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
|
||||
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
|
||||
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
|
||||
__NGINX_PORT__=${NGINX_PORT:=8999}
|
||||
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__BASE_MODULE__=${BASE_MODULE:=mempool}
|
||||
__ROOT_NETWORK__=${ROOT_NETWORK:=}
|
||||
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
|
||||
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
|
||||
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
|
||||
__LIGHTNING__=${LIGHTNING:=false}
|
||||
__AUDIT__=${AUDIT:=false}
|
||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__ACCELERATOR__=${ACCELERATOR:=false}
|
||||
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
|
||||
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
|
||||
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
|
||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
|
||||
|
||||
# Export as environment variables to be used by envsubst
|
||||
export __MAINNET_ENABLED__
|
||||
export __TESTNET_ENABLED__
|
||||
export __TESTNET4_ENABLED__
|
||||
export __SIGNET_ENABLED__
|
||||
export __LIQUID_ENABLED__
|
||||
export __LIQUID_TESTNET_ENABLED__
|
||||
export __ITEMS_PER_PAGE__
|
||||
export __KEEP_BLOCKS_AMOUNT__
|
||||
export __NGINX_PROTOCOL__
|
||||
export __NGINX_HOSTNAME__
|
||||
export __NGINX_PORT__
|
||||
export __BLOCK_WEIGHT_UNITS__
|
||||
export __MEMPOOL_BLOCKS_AMOUNT__
|
||||
export __BASE_MODULE__
|
||||
export __ROOT_NETWORK__
|
||||
export __MEMPOOL_WEBSITE_URL__
|
||||
export __LIQUID_WEBSITE_URL__
|
||||
export __MINING_DASHBOARD__
|
||||
export __LIGHTNING__
|
||||
export __AUDIT__
|
||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __ACCELERATOR__
|
||||
export __ACCELERATOR_BUTTON__
|
||||
export __SERVICES_API__
|
||||
export __PUBLIC_ACCELERATIONS__
|
||||
export __HISTORICAL_PRICE__
|
||||
export __ADDITIONAL_CURRENCIES__
|
||||
|
||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||
echo ${folder}
|
||||
envsubst < ${folder}/config.template.js > ${folder}/config.js
|
||||
|
||||
exec "$@"
|
||||
@@ -1,231 +0,0 @@
|
||||
# 1.8-alpha Improvements Tracker
|
||||
|
||||
Last updated: 2026-06-12 01:15 EDT
|
||||
|
||||
This tracks the user-facing improvement list that must land with the `1.8-alpha`
|
||||
container migration release and the next ISO cut produced from that release. It
|
||||
is intentionally separate from the container handoff docs, but should be treated
|
||||
as release and ISO smoke-test scope.
|
||||
|
||||
Status legend:
|
||||
|
||||
- `todo`: not started.
|
||||
- `in-progress`: active local work or validation.
|
||||
- `blocked`: needs host access, hardware, credentials, a product decision, or an
|
||||
external artifact.
|
||||
- `done`: implemented and validated for this release.
|
||||
- `defer?`: candidate to explicitly defer from `1.8-alpha` after product review.
|
||||
|
||||
Resume protocol:
|
||||
|
||||
1. Read this file after `docs/NEXT_TERMINAL_HANDOFF.md`.
|
||||
2. Keep every user-requested improvement represented here until it is either
|
||||
`done` or explicitly moved out of `1.8-alpha` by product decision.
|
||||
3. When implementation starts, change status to `in-progress` and add the file,
|
||||
test, host, or design decision being worked.
|
||||
4. Mark `done` only after the change is implemented and validated locally or on
|
||||
the release validation host, as appropriate.
|
||||
5. Before cutting the next ISO, run this checklist as part of ISO smoke testing.
|
||||
|
||||
Active-session note, 2026-06-10 05:48 EDT: resumed from
|
||||
`docs/NEXT_TERMINAL_HANDOFF.md`; no `.198` host actions have been run yet. The
|
||||
immediate tracker-affecting local gate is rerunning the focused Rust
|
||||
`container::image_versions::tests` validation for the Nextcloud false-update
|
||||
row, then continuing lifecycle/control-plane truthfulness work.
|
||||
|
||||
Resume-save checkpoint, 2026-06-10 08:32 EDT: the current pass stayed on the
|
||||
fixes backlog, not app migration. No `.198` host actions were run, no dev server
|
||||
was intentionally left running, and no long-running validation command is
|
||||
expected to still be active. Continue from the in-progress `Make tabs info load
|
||||
quickly or show loading states` row or the next unresolved fixes-backlog row.
|
||||
|
||||
Active-session progress: `git diff --check` passed. Focused image-version Rust
|
||||
validation is still inconclusive because the tool PTY stayed open with no
|
||||
active compiler process visible, a bounded 300s retry using the normal
|
||||
workspace target exited `124` before test output, and a fresh 600s retry in
|
||||
`/tmp/archy-cargo-image-versions-2` also exited `124` after compiling into the
|
||||
`archipelago` crate without reaching test output. The Nextcloud false-update
|
||||
row remains `in-progress`. A local lifecycle fix is in progress so migrated
|
||||
single-orchestrator app stops return immediately with a transitional state
|
||||
instead of blocking the UI while Podman cleanup runs; `cargo fmt --check` and
|
||||
focused backend compile check passed, and `git diff --check` is clean. Latest
|
||||
credentials backlog follow-up added backend PhotoPrism credentials, centered
|
||||
the mobile credential pre-launch modal in My Apps and the icon grid, and passed
|
||||
focused frontend tests, type-check, backend compile check, `cargo fmt --check`,
|
||||
and `git diff --check`. Web5 Connected Nodes Messages/Requests, Web5
|
||||
Identities, and DWN message browsing now preserve visible content during
|
||||
refresh/failure and show compact refresh labels instead of replacing populated
|
||||
tabs with loading panels; focused tests and type-check passed. Server Network
|
||||
overview, Network Interfaces, and Tor Services cards now keep visible values
|
||||
during refresh or refresh failure and show compact refresh labels instead of
|
||||
reverting to skeletons or false empty states; focused test and type-check
|
||||
passed. The standalone Credentials view now keeps credential rows visible
|
||||
during refresh/failure and shows `Refreshing credentials...`; focused test and
|
||||
type-check passed. Lightning Channels now keeps existing channels visible
|
||||
during refresh/failure and shows `Refreshing channels...`; focused test and
|
||||
type-check passed. Peer Files now keeps existing peer catalog items visible
|
||||
during Tor refresh/failure and shows `Refreshing peer files...`; focused test,
|
||||
type-check, and `git diff --check` passed. Cloud peer cards now remain visible
|
||||
during federation peer-list refresh/failure with `Refreshing peer nodes...`;
|
||||
focused test, type-check, and `git diff --check` passed. The Web5 Verifiable
|
||||
Credentials summary now keeps credential rows visible during refresh/failure
|
||||
with `Refreshing credentials...`; focused test, type-check, and
|
||||
`git diff --check` passed. Web5 Nostr Relays now keeps relay stats visible
|
||||
during refresh/failure with `Refreshing relays...`; focused test, type-check,
|
||||
and `git diff --check` passed. Web5 Domains now keeps registered-name counts
|
||||
visible during refresh/failure with `Refreshing domains...`; focused test,
|
||||
type-check, and `git diff --check` passed. Settings Backups now keeps existing
|
||||
backup rows visible during refresh/failure with `Refreshing backups...`;
|
||||
focused test, type-check, and `git diff --check` passed. Settings Transport
|
||||
Preferences now keeps preference controls visible during refresh/failure with
|
||||
`Refreshing transport preferences...`; focused test, type-check, and
|
||||
`git diff --check` passed. Settings VPN status now keeps current connection
|
||||
details visible during refresh/failure with `Refreshing VPN status...`;
|
||||
focused test, type-check, and `git diff --check` passed. Web5 Federation now
|
||||
shows `Refreshing federation...` during summary refresh and keeps existing node
|
||||
counts/DID visible on refresh failure; focused test, type-check, and
|
||||
`git diff --check` passed. Mesh map denied-location behavior now has component
|
||||
coverage proving browser location denial reports that peer positions can still
|
||||
appear without requiring local location; focused test, type-check, and
|
||||
`git diff --check` passed. Companion/app-session mobile tab-app handling now
|
||||
keeps apps that require a new tab inside the mobile session fallback instead of
|
||||
auto-opening an external tab and closing; focused app-session, launcher, and
|
||||
config tests passed with type-check and `git diff --check`.
|
||||
Nostr Discoverable Nodes now keeps discovered rows visible during relay refresh
|
||||
or relay failure and shows `Searching relays...`; focused test, type-check, and
|
||||
`git diff --check` passed. App Store/App Details screenshot sections now render
|
||||
only real screenshot metadata and no longer show fake placeholder tiles when no
|
||||
assets exist; focused App Details content and marketplace handoff tests,
|
||||
type-check, and `git diff --check` passed. Home now has an App Store
|
||||
recommendations card driven by uninstalled core/recommended marketplace apps;
|
||||
the recommendations respect installed aliases so apps drop out after install
|
||||
and move into normal My Apps/Home behavior. Focused helper tests, type-check,
|
||||
`git diff --check`, and the Playwright Home dashboard smoke passed. Easy Mode
|
||||
goal configure steps now route to their owning app/screen, verify steps have an
|
||||
explicit `Check & Continue` action, and configure/info/verify actions start
|
||||
goal progress before completing the step; focused goal action/store tests,
|
||||
type-check, and `git diff --check` passed. Setup path selection no longer shows
|
||||
the disabled `Connect Existing (Coming Soon)` option; Fresh Start and Restore
|
||||
from Seed are the only visible choices and route correctly. Focused onboarding
|
||||
option/composable tests, type-check, and `git diff --check` passed. Header
|
||||
responsiveness follow-up restored the primary My Apps/App Store/Websites
|
||||
navigation to persistent desktop tabs at `md+` on My Apps, Discover, and
|
||||
Marketplace; removed the desktop primary dropdowns; kept mobile dropdown
|
||||
behavior; delayed App Store category collapse by lowering the search reserve and
|
||||
header gap; and removed the My Apps desktop category dropdown. Focused
|
||||
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
|
||||
Browser smoke against the already-running local Vite/mock session is still next.
|
||||
|
||||
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
|
||||
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
|
||||
files exist, so failed legacy update attempts cannot leave the update screen
|
||||
permanently stuck. Direct `update.git-apply` is gated behind
|
||||
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
|
||||
the local git/self-build path that requires `cargo`. `.116` was recovered from a
|
||||
failed self-build attempt by applying its already-staged manifest OTA; it is now
|
||||
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
|
||||
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
|
||||
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
|
||||
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
|
||||
symbol linker failure unrelated to the updater patch.
|
||||
|
||||
Done criteria for this tracker:
|
||||
|
||||
- Code/UI items: implemented, covered by targeted test or manual smoke check,
|
||||
and no known regression against the container migration work.
|
||||
- Runtime/container items: validated on the release host named in
|
||||
`docs/NEXT_TERMINAL_HANDOFF.md`, then included in ISO smoke test scope.
|
||||
- Product-decision items: documented decision plus implementation task if the
|
||||
decision keeps it in `1.8-alpha`.
|
||||
- External/hardware items: hardware/document/access obtained, or explicitly
|
||||
deferred from the release by product decision.
|
||||
|
||||
## Release-Critical Runtime Gates
|
||||
|
||||
| Item | Status | Release question / blocker |
|
||||
| --- | --- | --- |
|
||||
| Check logs of every server for errors and fix | blocked | Needs explicit target server list. Current docs name `.198`; are there more production validation hosts? |
|
||||
| Go through issues on gate | blocked | Need location of "gate" issue tracker/board and access details. |
|
||||
| Sort out container tagging so databases, backend, etc are sorted properly | in-progress | Tie to manifest/catalog metadata and My Apps grouping. |
|
||||
| Sort out supplementary container naming so it is better | in-progress | Needs naming convention for dependencies: app-prefixed service names vs role-first names. |
|
||||
| Figure out how we offer updates to apps | todo | Product/runtime design needed: manual update, scheduled checks, or auto-update by app tier. |
|
||||
| Figure out how we provide different versions for Bitcoin to download and keep updated automatically | todo | Requires release policy for Knots/Core versions and whether users may pin old versions. |
|
||||
| Make sure all credentials are given for apps without registration | in-progress | File Browser now exposes credentials on App Details and in the pre-launch interstitial. Backend `package.credentials` returns the secured File Browser password from `/var/lib/archipelago/secrets/filebrowser/password` when present, with `admin/admin` fallback matching the install hook. PhotoPrism now exposes manifest-backed `admin` / `archipelago` credentials from both backend `package.credentials` and the frontend fallback. My Apps and mobile icon-grid credential pre-launch modals are vertically centered on mobile. Covered by `appCredentials.test.ts`, `AppIconGrid.test.ts`, local type-check, backend compile check, `cargo fmt --check`, and `git diff --check`. Grafana was not added because `GRAFANA_ADMIN_PASSWORD` is not resolved to a known repo default/secret. Remaining no-registration apps still need inventory. |
|
||||
| Nextcloud always shows update, and how are apps actually updated? | in-progress | Nextcloud manifest/catalog metadata is aligned to the pinned `nextcloud:29` image, and update detection now ignores registry-host-only image changes while still reporting real same-repo tag drift. Catalog drift check passed. Backend focused test was added but local validation hit a Rust linker/incremental artifact failure, then bounded retries exited `124` before test output, including a 600s fresh-target retry on 2026-06-10. Broader app update UX/policy design still needed. |
|
||||
| Make sure Tor is solid as having to rotate addresses to get it to work | todo | Needs `.198`/target-host Tor logs and reproducible failure case. |
|
||||
| Fix fleet it does not seem to work | done | Fleet data now preserves existing nodes during refresh, exposes an explicit refreshing state, sorts online nodes first, avoids duplicate history fetches when selecting a node, accepts backend `entries` and legacy `history` response shapes for per-node charts, and uses readable loading/auto-refresh UI. Covered by `useFleetData.test.ts`, local type-check, targeted tests, and user visual review of the Fleet header/card treatment. |
|
||||
| Check Beta Telemetry and how it works | done | Telemetry is opt-in via `analytics-config.json`; the background reporter runs every 15 minutes only when enabled, saves `telemetry-latest.json`, writes local Fleet reports/history under `telemetry-fleet/`, and optionally POSTs a `telemetry.ingest` JSON-RPC envelope to `TELEMETRY_COLLECTOR_URL`. The systemd unit now reads optional `/var/lib/archipelago/telemetry.env`, and deploys write that file when `TELEMETRY_COLLECTOR_URL` is exported in `scripts/deploy-config.sh`. Manual and periodic report schemas now both include metric percentages and container inventory, and the Fleet UI normalizes older reports with missing fields. Covered by local type-check, `useFleetData.test.ts`, `cargo check -p archipelago`, deploy-script syntax check, and `git diff --check`. Remaining ops step: choose the real collector URL, deploy it, restart the service, and confirm central Fleet ingest. |
|
||||
| Get Netbird working | todo | Requires app/runtime validation and credentials/config expectations. |
|
||||
| Sort out how we are going to manage lightning channel creation | todo | Product design needed for UX, safety limits, fees, and peer selection. |
|
||||
| Make sure old health notifications do not return on refresh/new login when stale/out of date | done | Health toasts now require a current app-linked unhealthy package state and hide stale package health notifications after 30 minutes on reload/new login. Backend monitoring notifications now prune duplicate active alerts and old generic alerts before pushing new ones. Covered by `HealthNotifications.test.ts`, local type-check, targeted frontend tests, and backend notification unit test work. |
|
||||
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
|
||||
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
|
||||
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
|
||||
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
|
||||
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
|
||||
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
|
||||
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
|
||||
| Check mesh core is picking up public channel/other devices, not just Archipelago ones | blocked | Needs Meshtastic hardware/radio environment. |
|
||||
| Make tabs info load quickly or show loading states | in-progress | Fleet now has initial loading/background-refresh states, and node history keeps showing while the next sample is fetched instead of blanking out. Web5 Connected Nodes Trusted/Observers tabs now show loading instead of empty states while peer data is pending and keep existing lists visible during refresh; Messages and Requests now also keep populated lists visible during refresh/failure. Web5 Shared Content now keeps My Content visible during refresh/failure with `Refreshing shared content...`, and Browse Peers keeps current same-peer results visible during refresh with `Refreshing peer content...` instead of replacing lists with full loading panels. Web5 Identities now keeps the identity list visible during refresh/failure with `Refreshing identities...`; Web5 DWN message browsing keeps stored messages visible during refresh/failure with `Refreshing messages...`. The Web5 Verifiable Credentials summary keeps credential rows visible during refresh/failure with `Refreshing credentials...`. Web5 Nostr Relays keeps relay stats visible during refresh/failure with `Refreshing relays...`. Web5 Domains keeps registered-name counts visible during refresh/failure with `Refreshing domains...`. Web5 Federation keeps summary node counts/DID visible during refresh/failure with `Refreshing federation...`. Server Network overview, Network Interfaces, and Tor Services cards now keep visible values during refresh/failure with `Refreshing network...`, `Refreshing interfaces...`, and `Refreshing Tor services...`. Credentials keeps credential rows visible during refresh/failure with `Refreshing credentials...`. Settings Backups keeps backup rows visible during refresh/failure with `Refreshing backups...`. Settings Transport Preferences keeps preference controls visible during refresh/failure with `Refreshing transport preferences...`. Settings VPN status keeps current connection details visible during refresh/failure with `Refreshing VPN status...`. Lightning Channels keeps existing channels visible during refresh/failure with `Refreshing channels...`. Peer Files keeps existing peer catalog items visible during Tor refresh/failure with `Refreshing peer files...`. Cloud keeps existing peer cards visible during federation peer-list refresh/failure with `Refreshing peer nodes...`. Covered by focused Web5/Server/Credentials/Backups/Transport/VPN/Lightning/Peer Files/Cloud tests and local type-check. Broader tab-info audit still needed for other slow panels before marking done. |
|
||||
| Add states about why Bitcoin address is not ready | in-progress | Receive Bitcoin on-chain flows now reject blank LND address responses and translate common LND/Bitcoin readiness failures into user-facing reasons: wallet locked, wallet uninitialized, Bitcoin/LND still syncing, LND unreachable, or LND REST/newaddress transport issues. The receive modals now show a live “checking wallet readiness” message while the request is in flight. Backend `lnd.newaddress` now errors if LND returns an error or no address. Needs live wallet-state smoke test before marking done. |
|
||||
| Add new Bitcoin wallets easily and securely | todo | Product/security design needed. |
|
||||
| Add the new gate instead of gate | blocked | Need definition of "new gate" and target integration. |
|
||||
| Local Nostr signer app should ask which account after logout/re-login | todo | Needs signer/session state validation. |
|
||||
| See what apps can migrate to local Nostr signer sign-in | todo | Needs app-by-app auth inventory. |
|
||||
| Make server name change change the host name | in-progress | Settings label changed to `Hostname`. `server.set-name` now persists the display name, derives a Linux-safe hostname slug, attempts `sudo -n hostnamectl set-hostname`, and returns non-fatal hostname warning fields if OS update fails. Covered by hostname slug unit test, local type-check, `cargo check -p archipelago`, and `git diff --check`. Impact audit: mDNS/SSH/Tailscale labels may change; already-created app configs using old `HOST_MDNS` (notably Fedimint derived env) are not automatically rewritten by hostnamectl, so this needs release-host smoke validation before marking done. |
|
||||
| Sort out HTTPS certificate, what is best way? | todo | Needs product decision: self-signed local CA, ACME DNS, Tailscale certs, or reverse proxy model. |
|
||||
|
||||
## User Interface And App Experience
|
||||
|
||||
| Item | Status | Release question / blocker |
|
||||
| --- | --- | --- |
|
||||
| LND Channels then back/back gets stuck between LND detail and channels | done | App Details back now routes explicitly to the parent surface, and Lightning Channels back replaces history so browser back no longer bounces between LND detail and Channels. Validated by local type-check and targeted tests. |
|
||||
| Add a Meshtastic icon | done | Added `meshcore.svg` asset and manifest-owned icon metadata. Catalog generation is idempotent and strict catalog drift is clean. |
|
||||
| Improve default app icon fallback | done | Missing/broken app icons now fall back to the centered Archipelago `A` mark using the same black fill and gradient-border treatment as the custom UI icon asset, instead of the old generic placeholder. Applied to My Apps cards, mobile icons, Marketplace cards, and App Details. Validated by local type-check, targeted tests, Rust check, and `git diff --check`. |
|
||||
| Use favicon for Portainer apps? | todo | Need decision: use upstream favicons dynamically or ship curated icons. |
|
||||
| Settings for apps | blocked | Needs definition: per-app config screen, runtime env vars, credentials, or install options? |
|
||||
| Update SearXNG app icon | blocked | Needs user-provided/approved icon asset. User said to move past this until they can make icons. |
|
||||
| Once an app is installed remove recommended/core pills | done | Marketplace cards hide tier badges when installed. Validated by `MarketplaceAppCard.test.ts`, targeted Vitest, type-check, and `git diff --check`. |
|
||||
| Get Bitcoin / LND UI fully done with all options and controls | todo | Large feature area; needs scope for `1.8-alpha` vs post-release. |
|
||||
| Fix intro always showing on new browser sessions | done | Splash gating now checks the backend onboarding-complete state before showing the intro when this browser has no local intro flag. Already-onboarded nodes skip the splash and seed `neode_intro_seen`; fresh installs still show it. Covered by `introSplash.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Fix App Store tabs/categories/search overflow | done | Discover/App Store and Marketplace render one shared App Store section list. Follow-up after user review restored the primary My Apps/App Store/Websites navigation to persistent desktop tabs at `md+` on My Apps, Discover, and Marketplace; mobile keeps dropdown behavior. App Store category collapse now happens later by starting uncollapsed and using a smaller header gap/search reserve, and the My Apps category dropdown no longer appears on desktop. Covered by local type-check, focused Marketplace/App config tests, and scoped `git diff --check`; browser smoke remains the next resume step. |
|
||||
| Add a test harness for all of the application | in-progress | Lifecycle harness exists; need expand UI/e2e coverage definition. |
|
||||
| Fix app details screen links | done | App Details sidebar no longer renders dead `href="#"` links. It now renders only real manifest website/marketing, upstream/wrapper repo, and support URLs, and hides the Links card when no usable URLs exist. Covered by `AppSidebar.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Fix FIPS anchoring, update FIPS | todo | Needs expected FIPS UX/API behavior. |
|
||||
| Fix generate receive address not working on nodes and identify wallet management | todo | Needs wallet API/backend validation. |
|
||||
| Fix mesh page on larger screens so it scales nicely | done | Mesh keeps the tabbed tools layout on normal desktop/1920px widths and only splits Off-Grid Bitcoin, Dead Man, and Map into separate stacked containers on very large screens (`>=2560px` wide and `>=1200px` tall). The desktop tools column now fills its panel instead of using a wrapper scroll container. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Mesh map should handle denied location permission and still show other devices | in-progress | Mesh map now treats browser geolocation as optional in the UI: denied local location reports that peer locations can still appear, and the empty hint waits for mesh device positions instead of saying location sharing is required. Covered by `MeshMap.test.ts`. Needs browser smoke test with denied location plus a peer coordinate message before marking done. |
|
||||
| Make tablet-size Meshtastic scrollable | done | Tablet/mobile Mesh tools panels now have bounded heights and internal scrolling so the selected Bitcoin/Dead Man/Map panel can scroll without blowing out the page. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Make mobile screens have gap below lowest container and tab bar | done | Dashboard route panels, including the separate Chat/Mesh branch, now use mobile tab-bar bottom clearance so the lowest content clears the bottom tab bar. |
|
||||
| Add Trusted tab to Connected Nodes container and have Peers and Observers | done | Connected Nodes now labels trusted peers as Trusted and splits federation nodes with `trust_level: observer` into the Observers tab. Observer nodes are excluded from Trusted, shown with their own count/badge, and refresh from the same live federation list. Validated by local type-check and targeted tests. |
|
||||
| Add more tree navigation to cloud files so they do not all go back to first screen | done | Cloud folder navigation now persists the current folder path in the route query so refresh/browser back keeps nested folders instead of resetting to the section root. The Cloud back button now walks up to the parent folder before returning to Cloud home. Covered by `cloudPath.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Fix visible UI refreshing on find nodes screens | done | Federation node auto-refresh no longer blanks/replaces the visible node lists after the initial load. Existing nodes stay visible during background refreshes, covered by `NodeList.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Remove dead UI components/ones that are coming soon | done | Removed the dead Web3/coming-soon Network card, disabled local-network placeholder button, and the non-interactive Spotlight AI Assistant coming-soon block. Verified active UI no longer contains explicit `Coming soon` copy outside historical release-note text. Covered by local type-check and `git diff --check`. |
|
||||
| Hide Web3 container on network for now and move FIPS Mesh up | done | Network page now places the live FIPS Mesh card in the top overview grid where the dead Web3 card was, removes the duplicate lower FIPS card, and updates the Home Network description to remove Web3 language. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Make cool screens less hidden: Find Nodes, Fleet, Monitoring, etc. | done | Existing Web5 summary cards now expose Monitoring, Find Nodes/Federation, and Fleet directly. Federation card has separate `Find Nodes` and `Fleet` actions instead of hiding Find Nodes behind Fleet. Covered by `Web5Federation.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Fix dashboard container/card square rendering corruption | done | Generalized the App Store compositor workaround to dashboard scroll-panel glass cards/buttons/inputs and removed transform-based stagger movement so Chromium/Brave no longer paints random large black square/rectangle layers over containers. Kept the Web5 bottom-action placement change. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Move constrained card header actions to bottom buttons | done | Web5 summary actions and Network actions for Add Device, Scan WiFi, Restart Tor, and Add Service now stay in the card header only on very wide screens; otherwise they render at the card bottom as full-width or 50/50 buttons. Button icons were removed from those action buttons. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
|
||||
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
|
||||
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
|
||||
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
|
||||
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
|
||||
| App Store screenshots | in-progress | Placeholder policy fixed: Marketplace App Details and installed App Details now render screenshot sections only when real screenshot metadata exists, and otherwise hide the fake placeholder tiles. Metadata can be string URLs or `{ src, alt }` objects. Covered by `AppContentSection.test.ts`, `useMarketplaceApp.test.ts`, local type-check, and `git diff --check`. Needs actual screenshot assets/metadata before marking done. |
|
||||
| Fix App Detail page issues; container controls are not good | done | App Details container controls now disable while start/stop/restart/update/uninstall RPCs are running and show action-specific progress labels. Header actions collapse into the bottom 50/50 grid below `1280px` to avoid tablet/smaller desktop overlap. Credentials now show a loading state while package credentials are being fetched. Covered by `AppHeroSection.test.ts`, `AppSidebar.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Add setup instructions for apps that need them | done | App Details now renders a dedicated Setup Instructions card from `static-files.instructions` when present, so apps can show install/setup notes without a new schema. Covered by `AppSidebar.test.ts`, local type-check, and `git diff --check`. |
|
||||
| Add press-and-hold option for apps on mobile app screen | done | Mobile My Apps icons now support long press/context menu to open the app detail/options screen while a normal tap still launches the app. Space key opens the same options path for keyboard users. Covered by `AppIconGrid.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Side-load: add port-not-available validation | done | Sideload modal now validates app ID collisions, malformed `host:container` mappings, reserved Archipelago/package host ports, and host ports already exposed by installed packages before queueing install. Backend install remains the final bind authority. Covered by `sideloadValidation.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
|
||||
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
|
||||
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
|
||||
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
|
||||
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
|
||||
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
|
||||
|
||||
## External / Hardware Items
|
||||
|
||||
| Item | Status | Release question / blocker |
|
||||
| --- | --- | --- |
|
||||
| Buy a HaLow device and start integration | blocked | Requires hardware purchase and driver/device target. Not a code-only `1.8-alpha` item unless hardware is available now. |
|
||||
@@ -1,96 +0,0 @@
|
||||
# Beta Test Issues — 2026-03-28 (ISO build 2137)
|
||||
|
||||
Hardware: Dell OptiPlex 3020M, i5, 8GB RAM, 465G HDD, UEFI+Legacy
|
||||
|
||||
## ISO / Boot (image-recipe)
|
||||
|
||||
### 1. UEFI autodetect broken
|
||||
- **Severity**: High
|
||||
- **Detail**: Only autodetects/boots in Legacy BIOS mode. UEFI boot does not autodetect the install disk.
|
||||
- **Where**: `build-auto-installer-iso.sh` GRUB config, EFI boot chain
|
||||
- **Status**: TODO
|
||||
|
||||
### 2. Installation TUI screens need redesign
|
||||
- **Severity**: Medium
|
||||
- **Detail**: Current installer output is plain/ugly. Needs polished design.
|
||||
- **Action**: User will provide .md mockup for each screen, then we implement.
|
||||
- **Where**: `build-auto-installer-iso.sh` auto-install.sh embedded script
|
||||
- **Status**: AWAITING DESIGN
|
||||
|
||||
### 3. No TUI animations
|
||||
- **Severity**: Low
|
||||
- **Detail**: Would like Claude-style spinner/progress animations during install. May not be possible with bash.
|
||||
- **Where**: auto-install.sh
|
||||
- **Status**: TODO (investigate)
|
||||
|
||||
### 4. USB read errors on boot
|
||||
- **Severity**: Medium (cosmetic but bad first impression)
|
||||
- **Detail**: Read errors scroll on screen during USB boot before installer loads. Scares new users.
|
||||
- **Where**: Kernel/initramfs boot, possibly `quiet` not suppressing early messages
|
||||
- **Status**: TODO
|
||||
|
||||
### 5. GRUB background tiling + text cutoff
|
||||
- **Severity**: Medium
|
||||
- **Detail**: Boot menu background image tiles instead of scaling. Menu text ("Install Archipelago", "Failsafe mode") is cut off.
|
||||
- **Where**: `branding/grub-theme/`, `boot/grub/grub.cfg`, theme.txt resolution settings
|
||||
- **Status**: TODO
|
||||
|
||||
### 6. USB removal drops to command line
|
||||
- **Severity**: Medium
|
||||
- **Detail**: After install completes, removing USB drops to shell before user presses Enter to reboot. Confuses non-technical users.
|
||||
- **Where**: auto-install.sh — end of install, before `read -s` / `reboot`
|
||||
- **Status**: TODO
|
||||
|
||||
## Frontend / UI (neode-ui)
|
||||
|
||||
### 7. Broken splash screen flashes before onboarding
|
||||
- **Severity**: High
|
||||
- **Detail**: Black screen with "online/offline" top-right, broken archipelago image top-left, "use arrow keys" text. Flashes briefly before onboarding loads.
|
||||
- **Where**: Likely `RootRedirect.vue` or `SplashScreen.vue` — routing/transition timing
|
||||
- **Status**: TODO (reported before, persists)
|
||||
|
||||
### 8. Skip buttons still visible in onboarding
|
||||
- **Severity**: Medium
|
||||
- **Detail**: Onboarding flow still shows skip buttons. Should be removed for clean UX.
|
||||
- **Where**: `src/views/onboarding/` components
|
||||
- **Status**: TODO
|
||||
|
||||
### 9. App install UX outdated
|
||||
- **Severity**: High
|
||||
- **Detail**: Missing the yellow "Installing..." button that persists across navigation. Apps don't show as "installing" in My Apps view during install.
|
||||
- **Where**: `src/views/marketplace/`, `src/views/myapps/`, app install store
|
||||
- **Status**: TODO
|
||||
|
||||
### 10. Login requires double Enter
|
||||
- **Severity**: Medium
|
||||
- **Detail**: Password field on login page requires pressing Enter twice to submit.
|
||||
- **Where**: `src/views/LoginView.vue` — form submission handler
|
||||
- **Status**: TODO (reported before, persists)
|
||||
|
||||
### 11. No password setting UI
|
||||
- **Severity**: High
|
||||
- **Detail**: No way for user to set/change their password from the web UI. Currently hardcoded `password123`.
|
||||
- **Where**: Settings view, backend auth API
|
||||
- **Status**: TODO
|
||||
|
||||
### 12. Browser login loops (non-kiosk)
|
||||
- **Severity**: High
|
||||
- **Detail**: Logging in from a browser (not kiosk) on the same network redirects back to login in a loop. Kiosk mode works fine.
|
||||
- **Where**: Auth/session handling — possibly cookie `SameSite` or redirect logic in `RootRedirect.vue`
|
||||
- **Status**: TODO
|
||||
|
||||
### 13. Can't exit input fields with arrow keys
|
||||
- **Severity**: Medium
|
||||
- **Detail**: When focused on a text input, up/down arrow keys don't move focus to adjacent UI elements. Stuck in the field.
|
||||
- **Where**: `useControllerNav.ts` — input field focus trap logic
|
||||
- **Status**: TODO (reported before, persists)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Critical | High | Medium | Low |
|
||||
|----------|----------|------|--------|-----|
|
||||
| ISO/Boot | 0 | 1 | 4 | 1 |
|
||||
| Frontend | 0 | 4 | 3 | 0 |
|
||||
| **Total** | **0** | **5** | **7** | **1** |
|
||||
@@ -1,335 +0,0 @@
|
||||
# Beta Progress Tracker
|
||||
|
||||
> **Goal**: Flawless beta that works perfectly on every machine we install it on.
|
||||
> **Freeze started**: 2026-03-18
|
||||
> **Last updated**: 2026-03-25
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
PHASE 1: Feature Testing (internal) ← WE ARE HERE
|
||||
↓
|
||||
PHASE 2: User Testing (real users, controlled)
|
||||
↓
|
||||
PHASE 3: Beta Live (public release)
|
||||
```
|
||||
|
||||
**Current phase**: PHASE 1 — Feature Testing
|
||||
**Gate to Phase 2**: Every feature works, all bugs fixed, security hardened, ISO verified
|
||||
**Gate to Phase 3**: User testing feedback resolved, no P0/P1 issues remaining
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Testing (Internal)
|
||||
|
||||
Everything in this phase must pass before we hand it to real users.
|
||||
|
||||
### Overall Status: IN PROGRESS (~65%)
|
||||
|
||||
| Workstream | Status | Completion | Gate-blocking? |
|
||||
|------------|--------|------------|----------------|
|
||||
| 1A. Critical Bugs (BUG-1 CSRF) | DONE | 100% | ~~YES~~ |
|
||||
| 1B. Boot Screen (FEATURE-4) | IN PROGRESS | ~80% (needs hardware test) | YES |
|
||||
| 1C. Security Hardening (TASK-8) | DONE (12/12 + code audit) | 100% | ~~YES~~ |
|
||||
| 1D. Rootless Podman (TASK-11) | DONE (.228), IN PROGRESS (.198) | ~80% | YES |
|
||||
| 1E. Beta Telemetry (TASK-12) | NOT STARTED | 0% | YES |
|
||||
| 1F. App Testing — every feature | NOT STARTED | 0% | YES |
|
||||
| 1G. ISO Build & Fresh Install | NOT STARTED | 0% | YES |
|
||||
| 1H. UI Polish & Layout | DONE (batch + What's New) | ~90% | No |
|
||||
| 1I. WebSocket Reliability | NOT STARTED | 0% | No |
|
||||
| 1J. Quality Baseline Check | NOT STARTED | 0% | No |
|
||||
| 1K. Architecture Review Fixes | DONE (4/4 items) | 100% | ~~YES~~ |
|
||||
| 1L. Update System (git.tx1138.com) | DONE | 100% | No |
|
||||
|
||||
### 1A. Critical Bugs
|
||||
|
||||
#### BUG-1: Random logout / CSRF mismatch — P0
|
||||
**Status**: PLANNED
|
||||
**Impact**: Users get randomly logged out. Blocks user testing — unacceptable UX.
|
||||
|
||||
**What's known**:
|
||||
- Sessions now persist to disk (fixed)
|
||||
- CSRF token mismatch between cookie and header still causes 403s
|
||||
- Likely caused by cookie rotation in multi-tab or deploy scenarios
|
||||
|
||||
**Remaining work**:
|
||||
- [ ] Add debug logging to capture actual cookie vs header values
|
||||
- [ ] Reproduce reliably (multi-tab, deploy, long idle)
|
||||
- [ ] Fix the root cause
|
||||
- [ ] Verify fix survives deploys and multi-tab use
|
||||
|
||||
#### BUG-3: IndeedHub WebSocket spam — P2
|
||||
**Status**: PLANNED
|
||||
**Impact**: Console noise, minor. Should fix before user testing.
|
||||
|
||||
- [ ] Rebuild IndeedHub with relative WebSocket URL
|
||||
- [ ] Verify fix
|
||||
|
||||
---
|
||||
|
||||
### 1B. Boot Screen (FEATURE-4)
|
||||
|
||||
**Status**: IN PROGRESS (~80% complete)
|
||||
**Impact**: Users hit errors on first boot before backend is ready. Blocks user testing.
|
||||
|
||||
- [x] Audit current `/health` endpoint — returns trivial "OK"
|
||||
- [x] Add granular service readiness to health endpoint (JSON with version + services)
|
||||
- [x] Design boot screen component — BootScreen.vue (379 lines, starfield + terminal log + orb)
|
||||
- [x] Create pixel art icon animations (6 SVG icons cycling)
|
||||
- [x] Implement health polling with smooth transition (server.echo RPC, 2s interval)
|
||||
- [x] Handle edge cases (timeout, 502/503 detection, boot-reset)
|
||||
- [ ] Test on fresh ISO install (first-boot path)
|
||||
- [ ] Test on normal reboot (existing user path)
|
||||
|
||||
---
|
||||
|
||||
### 1C. Security Hardening (TASK-8)
|
||||
|
||||
**Status**: DONE — 12/12 pentest findings fixed + additional hardening from code audit
|
||||
|
||||
#### Pentest (12/12 fixed)
|
||||
- [x] C1: /lnd-connect-info requires session auth
|
||||
- [x] C3: DEV_MODE removed from production service
|
||||
- [x] H1: node-message verifies ed25519 signatures
|
||||
- [x] H2: federation.peer-joined verifies ed25519 signature
|
||||
- [x] H3: federation.peer-address-changed requires signed proof
|
||||
- [x] H4: Backend binds to 127.0.0.1
|
||||
- [x] M1: content.add rejects `..` path traversal
|
||||
- [x] M2: NIP-07 postMessage uses specific origin
|
||||
- [x] M3: AIUI nginx checks session_id cookie
|
||||
- [x] L2: Strict v3 onion validation
|
||||
- [x] MED-03: Shell injection in bitcoin.conf generation
|
||||
- [x] MED-07: No body size limit on /rpc/
|
||||
|
||||
#### Code audit (additional)
|
||||
- [x] CSRF: HMAC-derived from session token (BUG-1 fix)
|
||||
- [x] Argon2id password hashing (bcrypt auto-upgrade)
|
||||
- [x] Random Bitcoin RPC password on first boot
|
||||
- [x] RBAC Viewer role: explicit allowlist
|
||||
- [x] Error sanitization tightened
|
||||
- [x] Identity label max length enforced
|
||||
- [ ] Cosign image verification (large scope — post-beta candidate)
|
||||
|
||||
---
|
||||
|
||||
### 1D. Rootless Podman (TASK-11)
|
||||
|
||||
**Status**: DONE on .228 (30 containers rootless), IN PROGRESS on .198
|
||||
**Impact**: Security posture — containers no longer require root.
|
||||
|
||||
- [x] Migrate existing root Podman containers to rootless (archipelago user)
|
||||
- [x] Update PodmanClient to run `podman` directly (no sudo) — 9 Rust files
|
||||
- [x] Deploy script auto-fixes ownership + sysctl + linger on every deploy
|
||||
- [x] All 30 containers running rootless on .228
|
||||
- [ ] .198: only 2 containers running — needs full container recreation (TASK-39)
|
||||
- [x] Tailscale deploy script: full deploy-tailscale.sh with split-mode SSH, rootful→rootless migration, container creation, all infrastructure
|
||||
- [ ] Test full deploy on .198 (validation before Tailscale)
|
||||
- [ ] Deploy to Tailscale nodes (Arch 1/2/3)
|
||||
|
||||
---
|
||||
|
||||
### 1E. Beta Telemetry — Node Reporting (TASK-12)
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Impact**: Without this we're blind during user testing — can't see what's broken on their machines.
|
||||
|
||||
All beta nodes report health/errors to a central log. We build a panel to monitor and triage issues.
|
||||
|
||||
**Design**:
|
||||
- Opt-in telemetry (user consents during onboarding or settings)
|
||||
- Each node periodically reports: health status, error log digest, container states, uptime
|
||||
- Central endpoint collects reports (could be a simple API on one of our servers)
|
||||
- Dashboard panel shows all reporting nodes, their status, recent errors
|
||||
- Privacy: no wallet data, no keys, no personal data — only system health and error logs
|
||||
- Nodes identified by anonymous ID (hash of DID), not IP or name
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Design report payload (health, errors, container states, versions, uptime)
|
||||
- [ ] Design privacy model — what's collected, what's NOT, user consent flow
|
||||
- [ ] Build reporting endpoint (backend RPC → central collector)
|
||||
- [ ] Build central collector service (receives + stores reports)
|
||||
- [ ] Build monitoring dashboard/panel (view all nodes, filter by error type)
|
||||
- [ ] Add opt-in toggle to Settings UI
|
||||
- [ ] Add reporting interval config (default: every 15 min?)
|
||||
- [ ] Test with multi-node fleet (.228, .198, Tailscale nodes)
|
||||
|
||||
---
|
||||
|
||||
### 1F. App Testing — Every Feature
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Reference**: `docs/BETA-RELEASE-CHECKLIST.md` — full matrix
|
||||
|
||||
Systematic test of **every feature** on the dev server, then on fresh install.
|
||||
|
||||
#### Core Flows
|
||||
- [ ] Onboarding: welcome → password → path → DID → backup → dashboard
|
||||
- [ ] Login / logout / re-login
|
||||
- [ ] Password change (invalidates other sessions)
|
||||
- [ ] 2FA enrollment and verification
|
||||
- [ ] Settings: view server name, version, DID, Tor address
|
||||
- [ ] Dashboard: all overview cards render with data
|
||||
|
||||
#### App Lifecycle (every app)
|
||||
- [ ] Bitcoin Knots: install, sync starts, UI loads, uninstall
|
||||
- [ ] Electrs: install, auto-connects to Bitcoin, UI loads, uninstall
|
||||
- [ ] LND: install, auto-connects to Bitcoin, UI loads, uninstall
|
||||
- [ ] BTCPay Server: install, connects, Lightning available, uninstall
|
||||
- [ ] Mempool: install with Bitcoin+Electrs, shows data, uninstall
|
||||
- [ ] Fedimint + Gateway: install, UI loads, uninstall
|
||||
- [ ] File Browser: install, UI loads, uninstall
|
||||
- [ ] Immich: install, UI loads, uninstall
|
||||
- [ ] PhotoPrism: install, UI loads, uninstall
|
||||
- [ ] Penpot: install, UI loads, uninstall
|
||||
- [ ] SearXNG: install, UI loads, uninstall
|
||||
- [ ] Ollama: install, UI loads, uninstall
|
||||
- [ ] Nostr Relay: install, UI loads, uninstall
|
||||
- [ ] Nginx Proxy Manager: install, UI loads, uninstall
|
||||
- [ ] Tailscale: install, UI loads, uninstall
|
||||
- [ ] Home Assistant: install, UI loads (new tab), uninstall
|
||||
- [ ] IndeedHub: opens external URL in iframe
|
||||
|
||||
#### Dependency Chain Errors
|
||||
- [ ] Electrs without Bitcoin → clear error message
|
||||
- [ ] LND without Bitcoin → clear error message
|
||||
- [ ] Mempool without Bitcoin+Electrs → clear error message
|
||||
|
||||
#### Federation & Identity
|
||||
- [ ] Federation invite + join between nodes
|
||||
- [ ] DWN sync between federated nodes
|
||||
- [ ] Backup create + download
|
||||
- [ ] Backup restore on fresh install
|
||||
|
||||
#### WebSocket
|
||||
- [ ] Connects on login, receives initial data
|
||||
- [ ] Reconnects after network drop
|
||||
- [ ] Ping/pong heartbeat both directions
|
||||
- [ ] Connection state visible in UI
|
||||
- [ ] Install progress delivered real-time
|
||||
|
||||
#### Nginx Proxies
|
||||
- [ ] Every `/app/*` proxy resolves correctly
|
||||
- [ ] BTCPay and Home Assistant open in new tab
|
||||
- [ ] Tor hidden services resolve
|
||||
|
||||
---
|
||||
|
||||
### 1G. ISO Build & Fresh Install
|
||||
|
||||
**Status**: NOT STARTED
|
||||
|
||||
- [ ] ISO builds successfully on dev server
|
||||
- [ ] ISO size < 10 GB
|
||||
- [ ] All container images captured
|
||||
- [ ] Boot from USB on x86_64 hardware
|
||||
- [ ] Auto-installer partitions correctly
|
||||
- [ ] Services start on first boot
|
||||
- [ ] Web UI accessible within 3 minutes
|
||||
- [ ] Full onboarding flow completes
|
||||
- [ ] Second machine test (different hardware)
|
||||
- [ ] ARM64 test (if targeting)
|
||||
|
||||
---
|
||||
|
||||
### 1H. UI Polish & Layout
|
||||
|
||||
**Status**: MOSTLY DONE — batch of fixes shipped 2026-03-18
|
||||
**Note**: Layout rearrangements and UX improvements allowed during freeze.
|
||||
|
||||
- [x] Rename fedimintd → "Fedimint Guardian" + icon (TASK-26)
|
||||
- [x] Tab-launch icons for apps opening in new tabs (TASK-27)
|
||||
- [x] Installed apps sorted to end of marketplace (TASK-28)
|
||||
- [x] Mesh mobile: header hidden, overflow fixed (TASK-29)
|
||||
- [x] On-Chain first in receive modals (TASK-30)
|
||||
- [x] Federation node names — show name not DID, hover for key (TASK-35)
|
||||
- [x] Cleaner iframe error screen with remediation (TASK-36)
|
||||
- [x] CPU alert threshold fixed (BUG-33)
|
||||
- [x] ElectrumX shows index size during indexing
|
||||
- [x] Container startup "Checking..." shimmer
|
||||
- [ ] Sticky nav header (TASK-31)
|
||||
- [ ] Review all views for consistent glass design
|
||||
- [ ] Verify all loading/empty/error states work
|
||||
- [ ] Check responsive layout on tablet/mobile
|
||||
|
||||
---
|
||||
|
||||
### 1I. WebSocket Reliability
|
||||
|
||||
Covered under 1F testing — no separate workstream needed.
|
||||
|
||||
---
|
||||
|
||||
### 1J. Quality Baseline Check
|
||||
|
||||
**Last known** (2026-03-11):
|
||||
- Silent catches: 0
|
||||
- Console statements: 0
|
||||
- `any` types: 0
|
||||
- TypeScript errors: 0
|
||||
- Tests: 515 passed
|
||||
- npm audit (runtime): 0
|
||||
|
||||
- [ ] Re-run full quality sweep — verify no regressions
|
||||
- [ ] Fix any new violations
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Testing (Controlled)
|
||||
|
||||
**Gate**: All Phase 1 items pass. No P0/P1 bugs open.
|
||||
|
||||
Starts when we hand ISOs to real users on real hardware we don't control.
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Recruit test users (3-5 people, varied hardware) | NOT STARTED |
|
||||
| Provide ISOs + install instructions | NOT STARTED |
|
||||
| Beta telemetry collecting reports from user nodes | NOT STARTED |
|
||||
| Monitor dashboard for errors across fleet | NOT STARTED |
|
||||
| Triage + fix reported issues | NOT STARTED |
|
||||
| User feedback collection (structured form or channel) | NOT STARTED |
|
||||
| Fix all P0/P1 issues from user reports | NOT STARTED |
|
||||
| Rebuild ISO with fixes, re-test | NOT STARTED |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Beta Live (Public)
|
||||
|
||||
**Gate**: User testing complete. No P0/P1 issues. Telemetry shows stable fleet.
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Final ISO build with all fixes | NOT STARTED |
|
||||
| Release notes / changelog | NOT STARTED |
|
||||
| Download page / distribution | NOT STARTED |
|
||||
| Public announcement | NOT STARTED |
|
||||
| Telemetry monitoring active for early adopters | NOT STARTED |
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Session | Work Done | Items Closed |
|
||||
|------|---------|-----------|--------------|
|
||||
| 2026-03-18 | #1 | Created beta freeze plan, progress tracker | — |
|
||||
| 2026-03-18 | #2 | Restructured into 3-phase pipeline, added telemetry workstream | — |
|
||||
| 2026-03-18 | #3 | Updated tracking to reflect completed work — TASK-11 done, TASK-8 9/12, UI batch done | TASK-11, TASK-26-30, TASK-32, TASK-34-36, BUG-33 |
|
||||
| 2026-03-18 | #4 | Rewrote deploy-tailscale.sh (full deploy with split-mode SSH, rootful migration, containers, infra). Fixed first-boot-containers.sh rootless bugs (subnet, UID mapping, prereqs). Dynamic HTTPS certs. | — |
|
||||
| 2026-03-18 | #5 | BUG-1 CSRF fix, TASK-8 12/12 done, 7 bugs fixed, Argon2id migration, random BTC RPC, RBAC hardened, What's New history, Bitcoin sync gauge. Tagged v1.2.0-alpha.9. | BUG-1, TASK-8, BUG-20/37/40/41, TASK-31/38 |
|
||||
| 2026-03-25 | #6 | Architecture review audit: all P0s+P1s verified fixed. Fixed remaining items: Nostr timeouts (6 calls), crypto dep pinning (12 deps), container image pinning (15 images), CI pipeline. Update system wired to git.tx1138.com. Cleaned stale branches. Docs updated. | Architecture review 4/4, CI pipeline |
|
||||
|
||||
---
|
||||
|
||||
## Post-Beta Parking Lot
|
||||
|
||||
These are explicitly deferred until after beta ships:
|
||||
- FEATURE-6: Watch-only wallet architecture
|
||||
- TASK-7: Mesh Bitcoin security hardening
|
||||
- INQUIRY-5: Offline balance check via mesh relay
|
||||
- TASK-2: Roll incoming-tx into deploy & ISO (P2, not blocking)
|
||||
- did:dht integration
|
||||
- Multi-user support
|
||||
- Cluster mode
|
||||
- Mobile companion PWA
|
||||
@@ -1,269 +0,0 @@
|
||||
# Beta Release Checklist (v0.5.0-beta)
|
||||
|
||||
## Pre-Build Verification
|
||||
|
||||
### Source Code
|
||||
|
||||
- [ ] All changes committed and pushed to `main`
|
||||
- [ ] `cargo clippy --all-targets --all-features` passes (zero warnings)
|
||||
- [ ] `cargo fmt --all` applied
|
||||
- [ ] `cd neode-ui && npm run type-check` passes (zero errors)
|
||||
- [ ] `cd neode-ui && npm test` passes (all tests green)
|
||||
- [ ] `cargo test --all-features` passes on dev server
|
||||
|
||||
### Critical Files
|
||||
|
||||
- [ ] `core/container/src/podman_client.rs` — rootless Podman REST API socket
|
||||
- [ ] `core/archipelago/src/container/docker_packages.rs` — app metadata + UI mapping
|
||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — app configs, capabilities, dependencies
|
||||
- [ ] `core/archipelago/src/session.rs` — session security hardening
|
||||
- [ ] `core/security/src/secrets_manager.rs` — encryption + rotation
|
||||
- [ ] `neode-ui/src/views/Marketplace.vue` — all app entries with pinned image versions
|
||||
- [ ] `neode-ui/src/api/websocket.ts` — heartbeat + reconnection
|
||||
- [ ] `image-recipe/configs/nginx-archipelago.conf` — all app proxies + path traversal blocks
|
||||
- [ ] All app icons present in `neode-ui/public/assets/img/app-icons/`
|
||||
|
||||
---
|
||||
|
||||
## App Integration Matrix
|
||||
|
||||
Every app must be tested for install, launch, and uninstall on a fresh system.
|
||||
|
||||
### Core Bitcoin Stack
|
||||
|
||||
| App | Image | Version | Install | Launch | UI Loads | Uninstall |
|
||||
|-----|-------|---------|---------|--------|----------|-----------|
|
||||
| Bitcoin Knots | `bitcoinknots/bitcoin` | `v28.1` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Electrs | `mempool/electrs` | `v0.4.1` | [ ] | [ ] | [ ] | [ ] |
|
||||
| LND | `lightninglabs/lnd` | `v0.18.4` | [ ] | [ ] | [ ] | [ ] |
|
||||
| BTCPay Server | `btcpayserver/btcpayserver` | `2.0.6` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Mempool | `mempool/frontend` | `v3.0.0` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Fedimint | `fedimintui/fedimint` | `0.5.0` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Fedimint Gateway | `fedimintui/gateway-ui` | `0.5.0` | [ ] | [ ] | [ ] | [ ] |
|
||||
|
||||
### Storage & Media
|
||||
|
||||
| App | Image | Version | Install | Launch | UI Loads | Uninstall |
|
||||
|-----|-------|---------|---------|--------|----------|-----------|
|
||||
| File Browser | `filebrowser/filebrowser` | `v2` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Immich | `ghcr.io/immich-app/immich-server` | `v1.121.0` | [ ] | [ ] | [ ] | [ ] |
|
||||
| PhotoPrism | `photoprism/photoprism` | `240915` | [ ] | [ ] | [ ] | [ ] |
|
||||
|
||||
### Productivity & Privacy
|
||||
|
||||
| App | Image | Version | Install | Launch | UI Loads | Uninstall |
|
||||
|-----|-------|---------|---------|--------|----------|-----------|
|
||||
| Penpot | `penpotapp/frontend` | `2.4` | [ ] | [ ] | [ ] | [ ] |
|
||||
| SearXNG | `searxng/searxng` | `2024.11.17-e2554de75` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Ollama | `ollama/ollama` | `0.5.4` | [ ] | [ ] | [ ] | [ ] |
|
||||
|
||||
### Network & Infrastructure
|
||||
|
||||
| App | Image | Version | Install | Launch | UI Loads | Uninstall |
|
||||
|-----|-------|---------|---------|--------|----------|-----------|
|
||||
| Nostr Relay | `scsiblade/nostr-rs-relay` | `0.9.0` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Nginx Proxy Manager | `jc21/nginx-proxy-manager` | `2.12.1` | [ ] | [ ] | [ ] | [ ] |
|
||||
| Tailscale | `tailscale/tailscale` | pinned | [ ] | [ ] | [ ] | [ ] |
|
||||
| Home Assistant | `homeassistant/home-assistant` | pinned | [ ] | [ ] | [ ] | [ ] |
|
||||
|
||||
### Virtual Apps (No Container)
|
||||
|
||||
| App | Behavior | Works |
|
||||
|-----|----------|-------|
|
||||
| IndeedHub | Opens external URL | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain Tests
|
||||
|
||||
These must be tested in order on a fresh install:
|
||||
|
||||
- [ ] Install Bitcoin Knots → starts and begins syncing
|
||||
- [ ] Install Electrs while Bitcoin running → connects to Bitcoin automatically
|
||||
- [ ] Install LND while Bitcoin running → connects to Bitcoin automatically
|
||||
- [ ] Install BTCPay while Bitcoin running → connects; Lightning available if LND present
|
||||
- [ ] Install Mempool while Bitcoin + Electrs running → shows blockchain data
|
||||
- [ ] Try installing Electrs without Bitcoin → shows clear error message
|
||||
- [ ] Try installing LND without Bitcoin → shows clear error message
|
||||
- [ ] Try installing Mempool without Bitcoin + Electrs → shows missing deps error
|
||||
- [ ] Fedimint Gateway auto-detects LND credentials when available
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening Verification
|
||||
|
||||
### Session Security
|
||||
|
||||
- [ ] Sessions expire after 24 hours of inactivity
|
||||
- [ ] Password change invalidates all other sessions
|
||||
- [ ] Maximum 5 concurrent sessions (oldest evicted when exceeded)
|
||||
- [ ] Session tokens are SHA-256 hashed in memory (never stored as plaintext)
|
||||
- [ ] Login rate limiting: 5 failures per 60 seconds per IP
|
||||
|
||||
### Container Security
|
||||
|
||||
- [ ] All container images use pinned versions (no `:latest`)
|
||||
- [ ] Read-only root filesystem enabled for compatible apps
|
||||
- [ ] `--cap-drop=ALL` applied to all containers
|
||||
- [ ] `--security-opt=no-new-privileges:true` applied to all containers
|
||||
- [ ] Required capabilities added explicitly per app (e.g., CHOWN for File Browser)
|
||||
|
||||
### Secrets Management
|
||||
|
||||
- [ ] Secrets encrypted with AES-256-GCM on disk
|
||||
- [ ] Secret metadata tracked (creation date, rotation count)
|
||||
- [ ] Secret rotation generates new random values and re-encrypts
|
||||
- [ ] `security.list-expiring` RPC returns secrets older than threshold
|
||||
|
||||
### Path Traversal Prevention
|
||||
|
||||
- [ ] Nginx blocks `..` in filebrowser API paths (403 response)
|
||||
- [ ] Frontend `sanitizePath()` strips `..` and resolves paths
|
||||
- [ ] File Browser token not exposed in URLs
|
||||
|
||||
### Authentication
|
||||
|
||||
- [ ] TOTP 2FA enrollment and verification works
|
||||
- [ ] TOTP backup codes work for recovery
|
||||
- [ ] Maximum 5 TOTP attempts before session invalidation
|
||||
- [ ] Pending TOTP sessions expire after 5 minutes
|
||||
- [ ] Cookie-based auth (no tokens in query strings)
|
||||
|
||||
---
|
||||
|
||||
## WebSocket & Connectivity
|
||||
|
||||
- [ ] WebSocket connects on login and receives initial data dump
|
||||
- [ ] WebSocket reconnects after network interruption (exponential backoff, max 30s)
|
||||
- [ ] Server sends ping every 30s; client responds with pong
|
||||
- [ ] Client sends JSON ping every 30s; server responds with JSON pong
|
||||
- [ ] Server closes inactive connections after 5 minutes
|
||||
- [ ] Connection state shown in UI (connected/reconnecting/disconnected)
|
||||
- [ ] Install progress updates delivered in real-time via WebSocket
|
||||
|
||||
---
|
||||
|
||||
## Fresh Install Testing Matrix
|
||||
|
||||
### ISO Build
|
||||
|
||||
- [ ] ISO builds successfully on dev server
|
||||
- [ ] ISO size is reasonable (< 10 GB)
|
||||
- [ ] All container images captured in ISO
|
||||
|
||||
### Installation
|
||||
|
||||
- [ ] Boot from USB on x86_64 hardware
|
||||
- [ ] Auto-installer partitions disk correctly
|
||||
- [ ] Debian 13 installs without errors
|
||||
- [ ] Archipelago services start on first boot
|
||||
- [ ] Web UI accessible at server IP within 3 minutes of first boot
|
||||
|
||||
### Onboarding Flow
|
||||
|
||||
- [ ] Welcome screen displays with intro video
|
||||
- [ ] Password creation enforces minimum requirements
|
||||
- [ ] Path selection shows all 6 options
|
||||
- [ ] DID generation completes within 60 seconds
|
||||
- [ ] Identity naming is optional and skippable
|
||||
- [ ] Backup download produces valid JSON file
|
||||
- [ ] Onboarding completes and reaches Dashboard
|
||||
|
||||
### Post-Onboarding
|
||||
|
||||
- [ ] Dashboard shows all overview cards
|
||||
- [ ] App Store loads with all curated apps
|
||||
- [ ] Settings shows server name, version, DID, Tor address
|
||||
- [ ] Logout and re-login works
|
||||
- [ ] Password change works and invalidates other sessions
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
- [ ] Backend startup: < 3 seconds
|
||||
- [ ] Frontend initial load: < 500 KB gzipped
|
||||
- [ ] WebSocket initial data: < 1 second after connection
|
||||
- [ ] App install progress visible in UI within 5 seconds of starting
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Verification
|
||||
|
||||
All app proxies must work in both HTTP and HTTPS blocks:
|
||||
|
||||
- [ ] `/rpc/` → backend:5678
|
||||
- [ ] `/ws/` → backend:5678 (WebSocket upgrade)
|
||||
- [ ] `/health` → backend:5678
|
||||
- [ ] `/app/filebrowser/` → filebrowser:80
|
||||
- [ ] `/app/searxng/` → searxng:8080
|
||||
- [ ] `/app/immich/` → immich:2283
|
||||
- [ ] `/app/penpot/` → penpot-frontend:80
|
||||
- [ ] `/app/ollama/` → ollama:11434
|
||||
- [ ] `/app/photoprism/` → photoprism:2342
|
||||
- [ ] `/app/nginx-proxy-manager/` → npm:81
|
||||
- [ ] `/app/tailscale/` → tailscale:8240
|
||||
- [ ] BTCPay (port 23000) opens in new tab
|
||||
- [ ] Home Assistant (port 8123) opens in new tab
|
||||
- [ ] Tor hidden services resolve for all configured apps
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedures
|
||||
|
||||
### If Backend Fails to Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
sudo journalctl -u archipelago -n 50 --no-pager
|
||||
|
||||
# Restore previous binary
|
||||
sudo cp /usr/local/bin/archipelago.bak /usr/local/bin/archipelago
|
||||
sudo systemctl restart archipelago
|
||||
```
|
||||
|
||||
### If Frontend is Broken
|
||||
|
||||
```bash
|
||||
# Restore previous frontend build
|
||||
sudo cp -r /opt/archipelago/web-ui.bak/* /opt/archipelago/web-ui/
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### If Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check container logs
|
||||
podman logs <container-name>
|
||||
|
||||
# Remove and recreate
|
||||
podman rm -f <container-name>
|
||||
# Reinstall from App Store
|
||||
```
|
||||
|
||||
### If ISO Install Fails
|
||||
|
||||
1. Boot into rescue mode from USB
|
||||
2. Check `/var/log/installer.log` on target disk
|
||||
3. Verify disk partitioning with `lsblk`
|
||||
4. Re-run installer with `INSTALLER_STARTED= /opt/installer.sh`
|
||||
|
||||
### Full System Rollback
|
||||
|
||||
If the beta is unusable:
|
||||
1. Re-flash the ISO from the last known good build
|
||||
2. Restore user data from `/var/lib/archipelago/` backup
|
||||
3. Re-import DID from backup JSON file
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Reviewer | Area | Date | Pass/Fail |
|
||||
|----------|------|------|-----------|
|
||||
| | Backend | | |
|
||||
| | Frontend | | |
|
||||
| | Security | | |
|
||||
| | ISO Build | | |
|
||||
| | Fresh Install | | |
|
||||
| | App Integrations | | |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user