Compare commits
154
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.zip, 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.zip"
|
||||
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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ class InputWebSocket(
|
||||
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
|
||||
var playerId: Int = 0
|
||||
|
||||
/**
|
||||
* Invoked when the kiosk asks us to open a URL in the phone's default
|
||||
* browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be
|
||||
* usefully opened on the kiosk, so the kiosk forwards them here.
|
||||
*/
|
||||
var onExternalOpen: ((String) -> Unit)? = null
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state
|
||||
|
||||
@@ -127,6 +134,20 @@ class InputWebSocket(
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
// The only inbound message we act on is an external-open request
|
||||
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
|
||||
try {
|
||||
val obj = org.json.JSONObject(text)
|
||||
if (obj.optString("t") == "o") {
|
||||
val url = obj.optString("url")
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
onExternalOpen?.invoke(url)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_state.value = ConnectionState.ERROR
|
||||
scheduleReconnect()
|
||||
|
||||
@@ -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,11 +63,26 @@ 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) }
|
||||
|
||||
// When the kiosk forwards an "open in external browser" app, launch it in
|
||||
// the phone's default browser.
|
||||
DisposableEffect(ws) {
|
||||
ws.onExternalOpen = { url ->
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
onDispose { ws.onExternalOpen = null }
|
||||
}
|
||||
|
||||
fun togglePlayer() {
|
||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||
ws.playerId = playerId
|
||||
@@ -98,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,
|
||||
@@ -159,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
NESMenu(
|
||||
visible = showModal,
|
||||
@@ -173,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,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
|
||||
@@ -28,8 +29,11 @@ import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
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 +45,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 +57,67 @@ 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)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||
@Composable
|
||||
fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
@@ -63,7 +129,12 @@ fun WebViewScreen(
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
||||
// 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 +203,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 +216,8 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
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 +225,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 +315,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 +328,9 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Handle window.open() — open in system browser
|
||||
// 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 +348,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 +415,140 @@ 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 minimal top bar
|
||||
* (close + title + escalate-to-real-browser). 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,
|
||||
) {
|
||||
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) }
|
||||
|
||||
// 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)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.close),
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInBrowser,
|
||||
contentDescription = stringResource(R.string.open_in_browser),
|
||||
tint = TextMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = BitcoinOrange,
|
||||
trackColor = SurfaceBlack,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
override fun onReceivedTitle(view: WebView?, t: String?) {
|
||||
if (!t.isNullOrBlank()) title = t
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Plain dark tile. The badge (single SVG-matched ring + grid) is in the
|
||||
foreground so there's exactly one ring. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#030202"
|
||||
android:fillColor="#0A0A0A"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
|
||||
<!-- Complete Archipelago badge (gradient ring + white grid) scaled to ~0.64 so
|
||||
the whole coin — including the ring — sits inside the adaptive safe zone and
|
||||
is never clipped. Ring is bold + bright (grey→white) to read on #0A0A0A. -->
|
||||
<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="1024"
|
||||
android:viewportHeight="1024">
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.72"
|
||||
android:scaleY="0.72">
|
||||
|
||||
<!-- 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" />
|
||||
<!-- Ringed circle -->
|
||||
<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>
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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" />
|
||||
</group>
|
||||
</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>
|
||||
@@ -21,4 +21,8 @@
|
||||
<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="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
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the Android companion app and publish it as the served download
|
||||
# (neode-ui/public/packages/archipelago-companion.apk.zip), 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.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")"
|
||||
rm -f "$DEST"
|
||||
( cd "$(dirname "$APK")" && zip -j -q "$ROOT/$DEST" "$(basename "$APK")" )
|
||||
|
||||
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,102 @@
|
||||
# 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.
|
||||
- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.
|
||||
- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.
|
||||
- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline.
|
||||
- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck.
|
||||
- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.
|
||||
- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.
|
||||
- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.
|
||||
- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label.
|
||||
- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files.
|
||||
- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default.
|
||||
|
||||
## v1.7.98-alpha (2026-06-16)
|
||||
|
||||
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).
|
||||
- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.
|
||||
- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.
|
||||
- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.
|
||||
- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.
|
||||
- Message notifications now have a close button and open the relevant chat when tapped.
|
||||
- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.
|
||||
- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).
|
||||
- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.
|
||||
- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.
|
||||
- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.
|
||||
|
||||
## v1.7.97-alpha (2026-06-16)
|
||||
|
||||
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
|
||||
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
|
||||
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
|
||||
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
|
||||
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
|
||||
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
|
||||
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
|
||||
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
|
||||
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
|
||||
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
|
||||
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
|
||||
|
||||
## v1.7.96-alpha (2026-06-15)
|
||||
|
||||
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
|
||||
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
|
||||
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
|
||||
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
|
||||
|
||||
## v1.7.95-alpha (2026-06-15)
|
||||
|
||||
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
|
||||
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
|
||||
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
|
||||
|
||||
## v1.7.94-alpha (2026-06-15)
|
||||
|
||||
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
|
||||
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
|
||||
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
|
||||
|
||||
## v1.7.93-alpha (2026-06-14)
|
||||
|
||||
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
|
||||
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
|
||||
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
|
||||
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
|
||||
|
||||
## v1.7.92-alpha (2026-06-14)
|
||||
|
||||
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
|
||||
|
||||
@@ -290,6 +290,18 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-clientd",
|
||||
"title": "Fedimint Client",
|
||||
"version": "0.8.0",
|
||||
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
|
||||
"repoUrl": "https://github.com/minmoto/fmcd"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-gateway",
|
||||
"title": "Fedimint Gateway",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
app:
|
||||
id: fedimint-clientd
|
||||
name: Fedimint Client
|
||||
version: 0.8.0
|
||||
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
|
||||
|
||||
container:
|
||||
# fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client
|
||||
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
|
||||
# this to the node registry. Pin the tag to match the REST shapes coded in
|
||||
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
|
||||
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
|
||||
# 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.
|
||||
secret_env:
|
||||
- key: FMCD_PASSWORD
|
||||
secret_file: fmcd-password
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# NOTE: this is a CLIENT, not the guardian — it does not require the local
|
||||
# `fedimint` app. It joins external federations (default below), so it can be
|
||||
# bundled standalone on every node.
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||
# 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
|
||||
# host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178.
|
||||
- host: 8178
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
# Same dir the first-boot bundled path uses + where the wallet bridge reads
|
||||
# the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fmcd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- FMCD_ADDR=0.0.0.0:8080
|
||||
- FMCD_MODE=rest
|
||||
- FMCD_DATA_DIR=/data
|
||||
# Default federation joined out-of-the-box (guardian on .116, iroh
|
||||
# transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so
|
||||
# it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in
|
||||
# core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate
|
||||
# join reliability from a real second node before relying on auto-bundle.
|
||||
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,42 @@
|
||||
app:
|
||||
id: fips-ui
|
||||
name: FIPS Mesh
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
|
||||
inside a container with host networking, serves a static dashboard on
|
||||
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
|
||||
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
|
||||
restart, and stable-channel daemon updates) go through the existing
|
||||
fips.* RPC methods, authenticated by the browser's own archipelago
|
||||
session — there is no separate secret to manage.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/fips-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/fips-ui:local
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8336 directly on the host IP and
|
||||
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8336
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -8,6 +8,12 @@ app:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
# Core — resolved at apply time from host facts (B12). Hardcoding either
|
||||
# breaks mempool's RPC connection on the other.
|
||||
derived_env:
|
||||
- key: CORE_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -47,7 +53,6 @@ app:
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
|
||||
Generated
+3077
-91
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.92-alpha"
|
||||
version = "1.7.99-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -9,6 +9,16 @@ authors = ["Archipelago Team"]
|
||||
name = "archipelago"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy
|
||||
# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a
|
||||
# scratch node before any fleet rollout. With the flag off, swarm::providers()
|
||||
# is empty and every fetch goes straight to the origin HTTP path (today's
|
||||
# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when
|
||||
# wiring the IrohProvider.
|
||||
iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -42,6 +52,7 @@ archipelago-performance = { path = "../performance" }
|
||||
# Authentication
|
||||
bcrypt = "0.15"
|
||||
sha2 = "0.10.9"
|
||||
blake3 = "1"
|
||||
hmac = "0.12.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
regex = "1.10"
|
||||
@@ -64,7 +75,7 @@ serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
@@ -106,6 +117,12 @@ sd-notify = "0.4"
|
||||
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||
async-trait = "0.1"
|
||||
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the
|
||||
# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the
|
||||
# flag so the default fleet build is unaffected until the PoC is measured.
|
||||
iroh = { version = "1", optional = true }
|
||||
iroh-blobs = { version = "0.103", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -66,6 +66,21 @@ impl ApiHandler {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
|
||||
// or X-Onchain-Address (on-chain) — both authorize the download if this
|
||||
// node issued+settled them, and both resolve against the same shared
|
||||
// entitlement store keyed by the token string (#46).
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
@@ -82,6 +97,7 @@ impl ApiHandler {
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
invoice_hash.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
@@ -130,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,
|
||||
@@ -140,6 +158,259 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let item = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
.rpc_handler
|
||||
.create_invoice(price_sats as i64, &memo)
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
"price_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
Ok(_) => Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
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:#}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether a previously-issued invoice has settled.
|
||||
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
|
||||
/// entitlement is marked paid so the buyer can then download the file.
|
||||
pub(super) async fn handle_content_invoice_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Could not generate an on-chain address (is the wallet ready?)"
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether an on-chain payment to a previously-
|
||||
/// issued address has arrived (>= price, >= 1 conf). Path:
|
||||
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
|
||||
pub(super) async fn handle_content_onchain_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, address) = match rest.split_once("/onchain-status/") {
|
||||
Some((id, addr)) => (id, addr),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown address"}"#),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
|
||||
pub(super) async fn handle_content_preview(
|
||||
path: &str,
|
||||
@@ -190,6 +461,14 @@ impl ApiHandler {
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
|
||||
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
||||
.header("Content-Type", "text/plain")
|
||||
.header("X-Content-Preview", "unavailable")
|
||||
.body(hyper::Body::from(
|
||||
"Preview unavailable for this media (needs re-encoding)",
|
||||
))
|
||||
.unwrap()),
|
||||
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct ApiHandler {
|
||||
session_store: SessionStore,
|
||||
/// Broadcast channel for relaying companion app input to remote browsers.
|
||||
input_relay_tx: broadcast::Sender<String>,
|
||||
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
|
||||
/// externally" requests here, and the companion (phone) socket forwards them
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
@@ -71,6 +76,7 @@ impl ApiHandler {
|
||||
.await?,
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
let (external_open_tx, _) = broadcast::channel(16);
|
||||
|
||||
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
@@ -100,6 +106,7 @@ impl ApiHandler {
|
||||
metrics_store,
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
@@ -202,6 +209,27 @@ impl ApiHandler {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A 401 that still carries CORS headers, for endpoints fetched
|
||||
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
|
||||
/// port). Without the ACAO header the browser surfaces an opaque CORS
|
||||
/// error instead of the 401, so the app can't tell it just needs auth.
|
||||
/// `origin` is the already-validated reflect value from `app_cors_origin`
|
||||
/// (empty string when the origin isn't allowed → no CORS header added).
|
||||
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Vary", "Origin");
|
||||
if !origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
builder.body(hyper::Body::from(body_bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
@@ -256,6 +284,45 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
|
||||
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
|
||||
/// host but use a different port, so the strict allowlist (`host_ip`, no
|
||||
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
|
||||
/// header ("blocked by CORS policy"). Reflect the Origin when its host
|
||||
/// matches the request's own `Host` header — i.e. the app lives on the same
|
||||
/// address the node is being reached by, which transparently covers the LAN
|
||||
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
|
||||
/// to enumerate them. Auth is still enforced by the session cookie; this
|
||||
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
|
||||
/// origin) when there is no match.
|
||||
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
|
||||
if let Some(origin) = self.validate_origin(headers) {
|
||||
return origin;
|
||||
}
|
||||
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
// host portion (no scheme, no port) of an `scheme://host[:port]` value
|
||||
let host_of = |s: &str| -> Option<String> {
|
||||
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let host = host_port
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(host_port);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
};
|
||||
let origin_host = host_of(origin);
|
||||
let req_host = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(host_of);
|
||||
match (origin_host, req_host) {
|
||||
(Some(o), Some(r)) if o == r => origin.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -265,9 +332,10 @@ impl ApiHandler {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
let preflight_origin = self.app_cors_origin(req.headers());
|
||||
if !preflight_origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Origin", &preflight_origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
@@ -295,7 +363,12 @@ impl ApiHandler {
|
||||
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
|
||||
return Self::handle_remote_input(
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Remote relay WebSocket — browser receives companion input events
|
||||
@@ -304,7 +377,12 @@ impl ApiHandler {
|
||||
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
|
||||
return Self::handle_remote_relay(
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
@@ -419,6 +497,22 @@ impl ApiHandler {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
}
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
|
||||
self.handle_content_invoice(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
}
|
||||
|
||||
// On-chain peer-file sale (#46): issue address / poll for payment
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
|
||||
self.handle_content_onchain(p).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
@@ -448,7 +542,8 @@ impl ApiHandler {
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
@@ -460,13 +555,26 @@ impl ApiHandler {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
// ("No 'Access-Control-Allow-Origin' header") instead of a
|
||||
// readable 401 the UI can act on.
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
|
||||
@@ -19,6 +19,8 @@ impl ApiHandler {
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted: bool,
|
||||
#[serde(default)]
|
||||
msg_id: Option<String>,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
@@ -26,6 +28,7 @@ impl ApiHandler {
|
||||
message: None,
|
||||
signature: None,
|
||||
encrypted: false,
|
||||
msg_id: None,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
|
||||
{
|
||||
@@ -152,7 +155,13 @@ impl ApiHandler {
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(&plaintext);
|
||||
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
|
||||
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
|
||||
node_msg::store_received(
|
||||
&clean_from,
|
||||
&clean_msg,
|
||||
clean_name.as_deref(),
|
||||
incoming.msg_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -99,33 +99,61 @@ impl ApiHandler {
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The LND wallet UI is served on its own APP_PORTS origin and fetches
|
||||
// this cross-origin, so it needs the CORS headers echoed back.
|
||||
let cors = |builder: hyper::http::response::Builder| {
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
};
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
Err(e) => Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
|
||||
// sends no macaroon. Use the shared authenticated client instead — the
|
||||
// same one lnd.getinfo and the wallet RPCs use.
|
||||
let request = match rpc.lnd_client().await {
|
||||
Ok((client, macaroon_hex)) => client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match request {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
@@ -157,4 +185,84 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
// Generous overall timeout: this endpoint serves both seek/Range
|
||||
// playback (small, finishes fast) and full-file downloads of large
|
||||
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
|
||||
// Tor and aborted the download mid-stream.
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(900));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
// Stream the peer's body straight through instead of buffering
|
||||
// the whole file into memory (#38). For a 178MB download the old
|
||||
// `resp.bytes().await` allocated the entire file on the node
|
||||
// before sending a byte; `wrap_stream` forwards chunks as they
|
||||
// arrive, with constant memory.
|
||||
Ok(builder
|
||||
.body(hyper::Body::wrap_stream(resp.bytes_stream()))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ impl ApiHandler {
|
||||
pub(super) async fn handle_remote_input(
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
@@ -266,6 +267,19 @@ impl ApiHandler {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward kiosk "open this URL externally" requests down to
|
||||
// the companion so the link opens in the phone's browser.
|
||||
ext = external_open_rx.recv() => {
|
||||
match ext {
|
||||
Ok(text) => {
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => {}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
|
||||
@@ -11,9 +11,16 @@ use super::ApiHandler;
|
||||
impl ApiHandler {
|
||||
/// WebSocket endpoint for browser clients to receive relayed companion input.
|
||||
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
|
||||
///
|
||||
/// The kiosk also uses this socket in the *reverse* direction: when an "open
|
||||
/// in external browser" app is launched, the kiosk can't usefully open it
|
||||
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
|
||||
/// URL and publish it on `external_open_tx`, which the companion (phone)
|
||||
/// socket forwards so the link opens in the phone's default browser.
|
||||
pub(super) async fn handle_remote_relay(
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
@@ -63,10 +70,20 @@ impl ApiHandler {
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
// Handle client-side messages (pong, close)
|
||||
// Handle client-side messages (pong, close, open-url requests)
|
||||
client_msg = rx.next() => {
|
||||
match client_msg {
|
||||
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// The only kiosk→server message we accept is an
|
||||
// external-open request: {"t":"o","url":"https://…"}.
|
||||
if let Some(url) = parse_open_url(&text) {
|
||||
debug!("Relaying external-open to companion: {}", url);
|
||||
let _ = external_open_tx.send(
|
||||
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -81,3 +98,29 @@ impl ApiHandler {
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
|
||||
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
|
||||
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
|
||||
/// compromised kiosk page can't push arbitrary URIs to the phone.
|
||||
fn parse_open_url(text: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
|
||||
return None;
|
||||
}
|
||||
let url = v.get("url").and_then(|u| u.as_str())?.trim();
|
||||
if url.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Some(url.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a string as a JSON string literal (with surrounding quotes).
|
||||
fn json_string(s: &str) -> String {
|
||||
serde_json::Value::String(s.to_string()).to_string()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ impl RpcHandler {
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
|
||||
@@ -234,7 +234,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -242,6 +242,15 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@@ -251,6 +260,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()));
|
||||
}
|
||||
@@ -294,13 +317,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -309,11 +340,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
let mut body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer catalog")?;
|
||||
|
||||
// Surface the transport that actually reached the peer so the cloud
|
||||
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"transport".to_string(),
|
||||
serde_json::Value::String(transport.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -353,25 +393,49 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str)
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment rejected by peer — token may have been insufficient or invalid"
|
||||
));
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
|
||||
}));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
@@ -389,6 +453,393 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
/// paid item so the buyer can pay from any external wallet. Returns the
|
||||
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
|
||||
pub(super) async fn handle_content_request_invoice(
|
||||
&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"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// 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(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Seller could not create an invoice ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): poll the selling node for invoice settlement.
|
||||
pub(super) async fn handle_content_invoice_status(
|
||||
&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"))?;
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
// Payment hash is hex from the seller; keep it strictly hex so it's safe
|
||||
// to interpolate into the request path.
|
||||
if payment_hash.is_empty()
|
||||
|| payment_hash.len() > 128
|
||||
|| !payment_hash.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
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(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// Treat an unreachable peer as "not yet paid" so the UI keeps polling.
|
||||
return Ok(serde_json::json!({ "paid": false, "unreachable": true }));
|
||||
}
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({ "paid": false }));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice-status response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): download a paid item after the invoice settled, passing
|
||||
/// the payment hash so the seller's content gate releases the file.
|
||||
pub(super) async fn handle_content_download_peer_invoice(
|
||||
&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"))?;
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", 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)
|
||||
.header("X-Invoice-Hash", payment_hash.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||
pub(super) async fn handle_content_request_onchain(
|
||||
&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"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// 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(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
||||
}));
|
||||
}
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Seller could not provide an address ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse onchain response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): poll the selling node for on-chain payment detection.
|
||||
pub(super) async fn handle_content_onchain_status(
|
||||
&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"))?;
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
|
||||
if address.is_empty()
|
||||
|| address.len() > 100
|
||||
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid address"));
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
||||
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(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })),
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({ "paid": false }));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse onchain-status response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): download a paid item after the on-chain payment was
|
||||
/// detected, passing the address so the seller's content gate releases it.
|
||||
pub(super) async fn handle_content_download_peer_onchain(
|
||||
&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"))?;
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", 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)
|
||||
.header("X-Onchain-Address", address.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for confirmation and retry."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch a preview of paid content from a peer (no payment required).
|
||||
pub(super) async fn handle_content_preview_peer(
|
||||
&self,
|
||||
@@ -418,13 +869,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -33,6 +33,7 @@ impl RpcHandler {
|
||||
"seed.restore" => self.handle_seed_restore(params).await,
|
||||
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
|
||||
"seed.status" => self.handle_seed_status().await,
|
||||
"seed.reveal" => self.handle_seed_reveal(params).await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
@@ -55,6 +56,7 @@ impl RpcHandler {
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
@@ -236,6 +238,11 @@ impl RpcHandler {
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
@@ -249,6 +256,7 @@ impl RpcHandler {
|
||||
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
|
||||
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
|
||||
"streaming.pay" => self.handle_streaming_pay(params).await,
|
||||
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
|
||||
"streaming.discover" => self.handle_streaming_discover().await,
|
||||
"streaming.usage" => self.handle_streaming_usage(params).await,
|
||||
"streaming.session" => self.handle_streaming_session(params).await,
|
||||
@@ -268,6 +276,16 @@ 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.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||
"content.download-peer-invoice" => {
|
||||
self.handle_content_download_peer_invoice(params).await
|
||||
}
|
||||
"content.request-onchain" => self.handle_content_request_onchain(params).await,
|
||||
"content.onchain-status" => self.handle_content_onchain_status(params).await,
|
||||
"content.download-peer-onchain" => {
|
||||
self.handle_content_download_peer_onchain(params).await
|
||||
}
|
||||
"content.preview-peer" => self.handle_content_preview_peer(params).await,
|
||||
|
||||
// DWN (Decentralized Web Node)
|
||||
@@ -379,6 +397,11 @@ impl RpcHandler {
|
||||
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
||||
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
||||
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
|
||||
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
|
||||
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
|
||||
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
|
||||
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
|
||||
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
|
||||
"mesh.test-send" => self.handle_mesh_test_send(params).await,
|
||||
|
||||
// Transport layer (unified routing)
|
||||
@@ -470,6 +493,11 @@ impl RpcHandler {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.get-source" => self.handle_update_get_source().await,
|
||||
"update.set-source" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_source(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -30,6 +30,25 @@ impl RpcHandler {
|
||||
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed every federation node from disk into the mesh peer table so the
|
||||
/// chat list reflects what the latest federation sync learned — display
|
||||
/// names (landed in `nodes.json` by `update_node_state` when a peer
|
||||
/// announces its name) and transitively-discovered peers (merged by
|
||||
/// `merge_transitive_peers`) — WITHOUT waiting for a mesh restart.
|
||||
///
|
||||
/// Without this, a peer accepted via invite (seeded with `name = None`)
|
||||
/// stays "Archipelago <pubkey8>" in chat until the next restart even after
|
||||
/// sync has learned its real name, and transitive peers never appear as
|
||||
/// chat contacts at all. `seed_federation_peers_into_mesh` is idempotent
|
||||
/// and dedups by onion, so calling it after each sync is safe.
|
||||
/// Best-effort: silently no-ops when mesh is off.
|
||||
pub(crate) async fn refresh_federation_mesh_peers(&self) {
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
mesh::seed_federation_peers_into_mesh(&svc.shared_state(), &self.config.data_dir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -243,9 +262,31 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
validate_did(did)?;
|
||||
|
||||
// Capture the node's pubkey before removal so we can also purge its
|
||||
// synthetic mesh contact/thread (#2) — remove_node only touches
|
||||
// nodes.json, which would otherwise leave a stale chat contact behind.
|
||||
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|nodes| nodes.into_iter().find(|n| n.did == did).map(|n| n.pubkey));
|
||||
|
||||
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
||||
info!(did = %did, "Removed node from federation");
|
||||
|
||||
if let Some(pubkey) = removed_pubkey.filter(|p| !p.is_empty()) {
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
let contact_id = mesh::federation_peer_contact_id(&pubkey);
|
||||
mesh::purge_federation_peer(
|
||||
&svc.shared_state(),
|
||||
contact_id,
|
||||
&pubkey,
|
||||
&self.config.data_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"removed": true,
|
||||
"nodes_remaining": nodes.len(),
|
||||
@@ -341,6 +382,10 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Push any names/roster the sync just learned into the live mesh peer
|
||||
// table so the chat list updates without a restart (#42).
|
||||
self.refresh_federation_mesh_peers().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"synced": synced,
|
||||
"failed": failed,
|
||||
@@ -533,6 +578,19 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
// Respect operator removal: a peer the operator deleted must not
|
||||
// silently re-join via a stale invite. The tombstone is only cleared
|
||||
// by an explicit local action (manually adding the node or accepting
|
||||
// an incoming invite) — not by a remote-triggered join.
|
||||
if federation::load_removed_dids(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains(did)
|
||||
{
|
||||
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
|
||||
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding
|
||||
//! Fedimint ecash is delegated to the clientd container via
|
||||
//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the
|
||||
//! node's JSON-RPC surface and keep a local registry of joined federations so
|
||||
//! the list survives clientd being temporarily unreachable.
|
||||
//!
|
||||
//! See `docs/dual-ecash-design.md`.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.fedimint-list` — joined federations with live balances.
|
||||
pub(super) async fn handle_wallet_fedimint_list(&self) -> Result<serde_json::Value> {
|
||||
// Best-effort: make sure the default federation is joined/tracked.
|
||||
let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await;
|
||||
|
||||
let reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
|
||||
// Live balances are best-effort: if clientd is down we still return the
|
||||
// tracked federations (with 0 balance) rather than failing the call.
|
||||
let info = match FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.info().await.ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let federations: Vec<serde_json::Value> = reg
|
||||
.federations
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let balance_sats = info
|
||||
.as_ref()
|
||||
.and_then(|i| i.get(&f.federation_id))
|
||||
.and_then(|e| {
|
||||
e.get("totalAmountMsat")
|
||||
.or_else(|| e.get("totalMsat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.map(|msat| msat / 1000)
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"federation_id": f.federation_id,
|
||||
"name": f.name,
|
||||
"balance_sats": balance_sats,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "federations": federations }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-join` — join a federation by invite code.
|
||||
pub(super) async fn handle_wallet_fedimint_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let invite_code = params
|
||||
.get("invite_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?;
|
||||
|
||||
let client = FedimintClient::from_node(&self.config.data_dir).await?;
|
||||
let federation_id = client.join(invite_code).await?;
|
||||
|
||||
// Try to label it from the federation meta (best-effort).
|
||||
let name = client.info().await.ok().and_then(|i| {
|
||||
i.get(&federation_id)
|
||||
.and_then(|e| e.get("meta"))
|
||||
.and_then(|m| {
|
||||
m.get("federation_name")
|
||||
.or_else(|| m.get("federation_expiry_timestamp"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
if !reg
|
||||
.federations
|
||||
.iter()
|
||||
.any(|f| f.federation_id == federation_id)
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id: federation_id.clone(),
|
||||
name,
|
||||
});
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "federation_id": federation_id }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-leave` — stop tracking a federation locally.
|
||||
pub(super) async fn handle_wallet_fedimint_leave(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let federation_id = params
|
||||
.get("federation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?;
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
let before = reg.federations.len();
|
||||
reg.federations.retain(|f| f.federation_id != federation_id);
|
||||
let removed = reg.federations.len() != before;
|
||||
if removed {
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
// wallet balance still renders from the Cashu side.
|
||||
let balance_sats = match 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": balance_sats }))
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,12 @@ impl RpcHandler {
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any
|
||||
// peers — almost always outbound UDP/8668 dropped by the
|
||||
// local firewall/router, or the anchor itself being down.
|
||||
"no_outbound_udp_or_anchor_down"
|
||||
// Daemon is up with a key but hasn't authenticated any peers —
|
||||
// almost always the outbound connection to the anchor being
|
||||
// dropped by the local firewall/router, or the anchor itself
|
||||
// being down. The public anchor is reached over TCP/8443 (not
|
||||
// UDP/8668 — that endpoint is dead).
|
||||
"no_outbound_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
@@ -126,8 +128,8 @@ impl RpcHandler {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"no_outbound_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
|
||||
@@ -14,10 +14,39 @@ impl RpcHandler {
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, default_id) = manager.list().await?;
|
||||
|
||||
// #49: The canonical node Nostr key is the node-level HKDF key
|
||||
// (`derive_node_nostr_key`) that Settings and Nostr discovery both use
|
||||
// via `node.nostr-pubkey`. The mirrored "Node" identity stores
|
||||
// nostr=None, and seed identities use a different BIP-32 NIP-06 key, so
|
||||
// the "Node" entry in Web5 > Identities disagreed with Settings. Resolve
|
||||
// the node-level key once and override it onto whichever identity record
|
||||
// is the node's own (its ed25519 matches `server_info.pubkey`), so both
|
||||
// surfaces always show the same npub. Display-only — no key is rewritten.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_nostr_hex = crate::nostr_discovery::get_nostr_pubkey(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
let node_nostr_npub = node_nostr_hex.as_ref().and_then(|h| {
|
||||
nostr_sdk::PublicKey::from_hex(h)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
});
|
||||
let (snapshot, _) = self.state_manager.get_snapshot().await;
|
||||
let node_pubkey_hex = snapshot.server_info.pubkey.clone();
|
||||
|
||||
let items: Vec<serde_json::Value> = identities
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let is_default = default_id.as_deref() == Some(&id.id);
|
||||
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
|
||||
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
node_nostr_npub.clone().or(id.nostr_npub),
|
||||
)
|
||||
} else {
|
||||
(id.nostr_pubkey, id.nostr_npub)
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id.id,
|
||||
"name": id.name,
|
||||
@@ -26,8 +55,8 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": id.nostr_pubkey,
|
||||
"nostr_npub": id.nostr_npub,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +151,250 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||
///
|
||||
/// 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,
|
||||
memo: &str,
|
||||
) -> Result<(String, String)> {
|
||||
if amount_sats < 0 {
|
||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||
}
|
||||
if memo.len() > 639 {
|
||||
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let invoice_body = serde_json::json!({
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
// 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
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice response")?;
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||
}
|
||||
|
||||
let payment_request = body
|
||||
.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// r_hash is base64 in LND's REST response — convert to hex.
|
||||
use base64::Engine;
|
||||
let payment_hash_hex = body
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok((payment_request, payment_hash_hex))
|
||||
}
|
||||
|
||||
/// Look up an invoice by hex payment hash; true if it has settled.
|
||||
pub(crate) async fn invoice_is_settled(&self, payment_hash_hex: &str) -> Result<bool> {
|
||||
if payment_hash_hex.is_empty() || hex::decode(payment_hash_hex).is_err() {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
// LND REST: GET /v1/invoice/{r_hash_str} where r_hash_str is hex.
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{payment_hash_hex}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to look up invoice")?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice lookup response")?;
|
||||
let settled = body
|
||||
.get("settled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
|| body.get("state").and_then(|v| v.as_str()) == Some("SETTLED");
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get new address")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("LND newaddress failed: {}", resp.status()));
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
body.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("LND newaddress returned no address"))
|
||||
}
|
||||
|
||||
/// True if an on-chain payment of >= `min_sats` to `address` has been seen
|
||||
/// with at least one confirmation (seller side, #46). Conservative on
|
||||
/// purpose: requires a confirmation + exact-address + sufficient-amount so a
|
||||
/// file sale is never released on an unconfirmed (reorg-able) tx.
|
||||
pub(crate) async fn onchain_received(&self, address: &str, min_sats: u64) -> Result<bool> {
|
||||
if address.is_empty() {
|
||||
return Err(anyhow::anyhow!("Empty address"));
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to list transactions")?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 {
|
||||
tx.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for tx in &txs {
|
||||
if i64_field(tx, "num_confirmations") < 1 {
|
||||
continue;
|
||||
}
|
||||
if i64_field(tx, "amount") < min_sats as i64 {
|
||||
continue;
|
||||
}
|
||||
let pays_addr = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
|
||||
.unwrap_or(false)
|
||||
|| tx
|
||||
.get("output_details")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if pays_addr {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -183,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
|
||||
@@ -552,8 +806,15 @@ impl RpcHandler {
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
// Use the per-node secret as the LND wallet password (NOT the
|
||||
// caller-supplied one) so the unattended boot path can auto-unlock this
|
||||
// wallet. The wallet stays recoverable from the Archipelago seed via the
|
||||
// derived entropy above. This unifies both init paths on one password
|
||||
// source — the divergence here is what left wallets locked fleet-wide.
|
||||
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
|
||||
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
|
||||
//! and report whether a local Ollama is available (for the install deep-link).
|
||||
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default model when the node hasn't picked one (kept in sync with the mesh
|
||||
/// assistant handler's `DEFAULT_MODEL`).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.assistant-status — current settings + local Ollama availability.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
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_denied_askers().await)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
let claude_available =
|
||||
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.is_ok();
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.assistant-configure — update assistant settings live.
|
||||
/// Params: `enabled?: bool`, `trusted_only?: bool`,
|
||||
/// `model?: string|null` (string sets, null clears to default, absent leaves).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let enabled = params.get("enabled").and_then(|v| v.as_bool());
|
||||
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
|
||||
let backend = params
|
||||
.get("backend")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// model: key present + string => set; present + null => clear; absent => leave
|
||||
let model = if let Some(v) = params.get("model") {
|
||||
Some(v.as_str().map(|s| s.to_string()))
|
||||
} 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, allowed_contacts)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.schedule-message — queue a message to send at a future time.
|
||||
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
|
||||
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let body = p
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("body is required"))?
|
||||
.to_string();
|
||||
let fire_at = p
|
||||
.get("fire_at")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
|
||||
let contact_id = p
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
|
||||
if contact_id.is_none() && channel.is_none() {
|
||||
anyhow::bail!("either contact_id or channel is required");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let msg = svc
|
||||
.scheduler
|
||||
.add(contact_id, channel, body, fire_at)
|
||||
.await?;
|
||||
Ok(serde_json::to_value(msg)?)
|
||||
}
|
||||
|
||||
/// mesh.list-scheduled — list queued messages (sorted by fire time).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let messages = svc.scheduler.list().await;
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
/// mesh.cancel-scheduled — remove a queued message by id.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let cancelled = svc.scheduler.cancel(id).await?;
|
||||
Ok(serde_json::json!({ "cancelled": cancelled }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the local Ollama HTTP API; return (detected, model_names).
|
||||
async fn detect_ollama() -> (bool, Vec<String>) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return (false, Vec::new()),
|
||||
};
|
||||
match client.get("http://localhost:11434/api/tags").send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let models = json
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(true, models)
|
||||
}
|
||||
_ => (false, Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,18 @@ impl RpcHandler {
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.announce_block_headers = announce;
|
||||
}
|
||||
if let Some(receive) = params
|
||||
.get("receive_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.receive_block_headers = receive;
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
@@ -124,6 +136,8 @@ impl RpcHandler {
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
"announce_block_headers": config.announce_block_headers,
|
||||
"receive_block_headers": config.receive_block_headers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
|
||||
@@ -5,26 +5,39 @@ use anyhow::Result;
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
// Block-header send/receive prefs live in MeshConfig; surface them in
|
||||
// status so the UI toggles (issue #28) can show the persisted state.
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let mut value = if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
serde_json::to_value(status)?
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
})
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"announce_block_headers".into(),
|
||||
config.announce_block_headers.into(),
|
||||
);
|
||||
obj.insert(
|
||||
"receive_block_headers".into(),
|
||||
config.receive_block_headers.into(),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
@@ -245,43 +258,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();
|
||||
|
||||
@@ -1184,6 +1184,12 @@ impl RpcHandler {
|
||||
entry.pinned = p;
|
||||
}
|
||||
let saved = entry.clone();
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
// Persist (encrypted, atomic) so the customisation survives restarts.
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"saved": true,
|
||||
"pubkey": pubkey,
|
||||
@@ -1215,6 +1221,11 @@ impl RpcHandler {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
let entry = contacts.entry(pubkey.clone()).or_default();
|
||||
entry.blocked = blocked;
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ mod credentials;
|
||||
mod dispatcher;
|
||||
mod dwn;
|
||||
mod federation;
|
||||
mod fedimint;
|
||||
mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
|
||||
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
| "fips"
|
||||
| "fips-ui"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -347,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
|
||||
@@ -672,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).
|
||||
@@ -687,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(),
|
||||
]),
|
||||
@@ -750,27 +777,33 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => {
|
||||
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
|
||||
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
(
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
|
||||
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the container name of the running Bitcoin node so dependent stacks
|
||||
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
|
||||
/// Core are both reachable on archy-net by their container name — only the name
|
||||
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
|
||||
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
|
||||
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
|
||||
pub(super) async fn detect_bitcoin_rpc_host() -> String {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(Ok(o)) = out {
|
||||
if o.status.success() {
|
||||
let running = String::from_utf8_lossy(&o.stdout);
|
||||
if let Some(name) = pick_bitcoin_host(&running) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
|
||||
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
|
||||
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
|
||||
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
|
||||
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
|
||||
podman_names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_host_tests {
|
||||
use super::pick_bitcoin_host;
|
||||
|
||||
#[test]
|
||||
fn picks_knots() {
|
||||
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_core() {
|
||||
let ps = "lnd\nbitcoin-core\nelectrumx\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_plain_bitcoin() {
|
||||
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_when_no_bitcoin_node() {
|
||||
let ps = "electrumx\nlnd\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_substring_matches() {
|
||||
// A companion UI container must NOT be mistaken for the node itself.
|
||||
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
|
||||
@@ -434,6 +434,13 @@ async fn wait_for_stack_containers(
|
||||
containers: &[&str],
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
// A container can exit on its first start because a dependency (db, redis,
|
||||
// the bitcoin node) was not quite ready — a transient crash, not a broken
|
||||
// install. Restart each exited container a bounded number of times before
|
||||
// declaring the install failed (#25). The runtime supervisor keeps it alive
|
||||
// afterwards, but we want a healthy state by the time install returns.
|
||||
const MAX_RESTARTS: u32 = 3;
|
||||
let mut restarts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let mut pending = Vec::new();
|
||||
@@ -449,20 +456,41 @@ async fn wait_for_stack_containers(
|
||||
match state.as_str() {
|
||||
"running" => {}
|
||||
"exited" | "dead" => {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install. Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
let attempts = restarts.entry(container.to_string()).or_insert(0);
|
||||
if *attempts < MAX_RESTARTS {
|
||||
*attempts += 1;
|
||||
install_log(&format!(
|
||||
"INSTALL RESTART: {} - container {} exited, restart attempt {}/{}",
|
||||
stack_name, container, *attempts, MAX_RESTARTS
|
||||
))
|
||||
.await;
|
||||
let _ = podman_stack_output(
|
||||
&["start", container],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
pending.push(format!(
|
||||
"{}=restarting({}/{})",
|
||||
container, *attempts, MAX_RESTARTS
|
||||
));
|
||||
} else {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited after {} restarts. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
MAX_RESTARTS,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install ({} restarts). Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
MAX_RESTARTS,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
}
|
||||
}
|
||||
other => pending.push(format!("{}={}", container, other)),
|
||||
}
|
||||
@@ -1152,6 +1180,9 @@ impl RpcHandler {
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("mempool", &deps)?;
|
||||
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// CORE_RPC_HOST must match the actual Bitcoin node container name —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC (B12).
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
|
||||
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
|
||||
|
||||
@@ -1275,7 +1306,7 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"ELECTRUM_TLS_ENABLED=false",
|
||||
"-e",
|
||||
"CORE_RPC_HOST=bitcoin-knots",
|
||||
&format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"-e",
|
||||
"CORE_RPC_PORT=8332",
|
||||
"-e",
|
||||
@@ -1776,14 +1807,22 @@ impl RpcHandler {
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
write_netbird_config_files(&host_ip).await?;
|
||||
|
||||
// Create the network FIRST so we can read back the gateway it was
|
||||
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
|
||||
// nginx needs as an explicit `resolver` to re-resolve container names
|
||||
// (issue #15: without it nginx caches a container IP and 502s forever
|
||||
// once that IP changes on restart/reboot).
|
||||
let _ = podman_stack_status(
|
||||
&["network", "create", "netbird-net"],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolver_ip = netbird_net_resolver_ip().await;
|
||||
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
|
||||
ensure_netbird_tls_cert(&host_ip).await?;
|
||||
|
||||
let mut server_cmd = tokio::process::Command::new("podman");
|
||||
server_cmd.args([
|
||||
"run",
|
||||
@@ -1821,6 +1860,10 @@ impl RpcHandler {
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
// Explicit alias so the proxy can always resolve `netbird-dashboard`
|
||||
// via Podman DNS — don't rely on implicit container-name aliasing.
|
||||
"--network-alias",
|
||||
"netbird-dashboard",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
@@ -1837,10 +1880,16 @@ impl RpcHandler {
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
// 8087 publishes the TLS listener — netbird's dashboard requires a
|
||||
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
|
||||
"-p",
|
||||
"8087:80",
|
||||
"8087:443",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
@@ -1885,9 +1934,104 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
secret
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
let public_origin = format!("http://{}:8087", host_ip);
|
||||
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
|
||||
/// resolver on this address, so nginx can use it as an explicit `resolver` to
|
||||
/// re-resolve container names at request time. Falls back to Podman's usual
|
||||
/// first-pool gateway if the inspect fails (best effort — config is rewritten
|
||||
/// on every (re)install).
|
||||
async fn netbird_net_resolver_ip() -> String {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"inspect",
|
||||
"netbird-net",
|
||||
"--format",
|
||||
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = out {
|
||||
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
||||
return gw;
|
||||
}
|
||||
}
|
||||
"10.89.0.1".to_string()
|
||||
}
|
||||
|
||||
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
|
||||
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
|
||||
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
|
||||
/// once when opening netbird in a tab). SAN covers the LAN IP plus
|
||||
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
|
||||
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
||||
let dir = "/var/lib/archipelago/netbird";
|
||||
let crt = format!("{dir}/tls.crt");
|
||||
let key = format!("{dir}/tls.key");
|
||||
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = tokio::fs::create_dir_all(dir).await;
|
||||
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
|
||||
let status = tokio::process::Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
&key,
|
||||
"-out",
|
||||
&crt,
|
||||
"-days",
|
||||
"3650",
|
||||
"-subj",
|
||||
&format!("/CN={host_ip}"),
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.status()
|
||||
.await
|
||||
.context("failed to run openssl for netbird TLS cert")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("openssl failed to generate netbird TLS cert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
|
||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
||||
// origin here is https (issue #15: over plain http the dashboard threw
|
||||
// "window.crypto.subtle is unavailable" and never reached login).
|
||||
let public_origin = format!("https://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
// A single box is reached via several addresses. Allow the OIDC login flow
|
||||
// to redirect back to whichever origin the user actually used, otherwise
|
||||
// post-login lands on the wrong host and the dashboard shows
|
||||
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
|
||||
// nginx proxy; this covers the redirect-URI allow-list.
|
||||
let lan_origin = format!("https://{}:8087", lan_ip);
|
||||
let mut redirect_origins = vec![public_origin.clone()];
|
||||
if lan_origin != public_origin {
|
||||
redirect_origins.push(lan_origin);
|
||||
}
|
||||
let dashboard_redirect_uris = redirect_origins
|
||||
.iter()
|
||||
.flat_map(|o| {
|
||||
[
|
||||
format!(" - \"{o}/nb-auth\""),
|
||||
format!(" - \"{o}/nb-silent-auth\""),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let dashboard_logout_uris = redirect_origins
|
||||
.iter()
|
||||
.map(|o| format!(" - \"{o}/\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
@@ -1907,10 +2051,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
- "{public_origin}/nb-auth"
|
||||
- "{public_origin}/nb-silent-auth"
|
||||
{dashboard_redirect_uris}
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "{public_origin}/"
|
||||
{dashboard_logout_uris}
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
@@ -1944,12 +2087,23 @@ LETSENCRYPT_DOMAIN=none
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# Route browser API/auth through the host-published server port. Rootless
|
||||
# Podman can give netbird-server a new container IP on restart while nginx
|
||||
# keeps an old resolved address, which breaks login with 502s.
|
||||
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
|
||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||
# nginx resolves a literal upstream name ONCE at startup and caches it, so
|
||||
# after the IP moves every request 502s with "host unreachable" (issue #15,
|
||||
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
|
||||
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
|
||||
# use VARIABLE upstreams, which forces nginx to re-resolve the container
|
||||
# names at request time. Everything is reached container-to-container by
|
||||
# name so nothing depends on host-published ports either.
|
||||
resolver {resolver_ip} valid=10s ipv6=off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -1958,24 +2112,60 @@ LETSENCRYPT_DOMAIN=none
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
|
||||
# to one host:port. A single box is reached via several addresses (LAN
|
||||
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
|
||||
# the browser blocks them with no Access-Control-Allow-Origin (issue
|
||||
# #15, observed live on .198). Reflect the caller's Origin so the
|
||||
# self-hosted management/OIDC API is reachable from any of them, and
|
||||
# answer the CORS preflight here.
|
||||
if ($request_method = OPTIONS) {{
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}}
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
||||
grpc_pass grpc://netbird-server:80;
|
||||
set $nb_server netbird-server;
|
||||
grpc_pass grpc://$nb_server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
|
||||
# the dashboard bundle, so proxying them straight through 404s — which
|
||||
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
|
||||
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
|
||||
# returned 404). Serve the dashboard's index.html at these paths (URL
|
||||
# unchanged) so react-oidc boots and completes the login / silent-SSO.
|
||||
location ~ ^/(nb-auth|nb-silent-auth) {{
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
rewrite ^.*$ /index.html break;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
proxy_pass http://netbird-dashboard:80;
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -1996,10 +2186,32 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
let ips: Vec<&str> = stdout
|
||||
.split_whitespace()
|
||||
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||
.map(str::to_string)
|
||||
.filter(|s| s.contains('.'))
|
||||
.collect();
|
||||
|
||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
||||
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
|
||||
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
|
||||
let is_private_lan = |ip: &str| {
|
||||
ip.starts_with("192.168.")
|
||||
|| ip.starts_with("10.")
|
||||
|| (ip.starts_with("172.")
|
||||
&& ip
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.and_then(|o| o.parse::<u8>().ok())
|
||||
.map(|o| (16..=31).contains(&o))
|
||||
.unwrap_or(false))
|
||||
};
|
||||
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
|
||||
return Some(lan.to_string());
|
||||
}
|
||||
ips.iter()
|
||||
.find(|ip| ip.starts_with("100."))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -32,8 +32,11 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Verify an update is actually available
|
||||
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||
// Verify an update is actually available. Prefer the remote app catalog
|
||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
||||
// pin when the catalog is absent or doesn't cover this app.
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
@@ -149,6 +152,28 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// package scanner recomputes each app's `available-update` from the fresh
|
||||
/// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
|
||||
/// failure leaves the cached catalog in place and reports `refreshed: false`.
|
||||
pub(in crate::api::rpc) async fn handle_package_check_updates(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(count) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": count,
|
||||
})),
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||
async fn execute_update(
|
||||
&self,
|
||||
@@ -385,13 +410,24 @@ impl RpcHandler {
|
||||
package_id: &str,
|
||||
pinned_primary: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
let mut stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
if stack_images.is_empty() {
|
||||
// Single container app
|
||||
vec![(package_id.to_string(), pinned_primary.to_string())]
|
||||
} else {
|
||||
stack_images
|
||||
// Single container app — pinned_primary already prefers the catalog.
|
||||
return vec![(package_id.to_string(), pinned_primary.to_string())];
|
||||
}
|
||||
// Stack app: override per-container images with the catalog where it
|
||||
// provides them; components the catalog omits keep the image-versions.sh
|
||||
// pin. This lets a single component (e.g. the IndeeHub frontend) be
|
||||
// bumped without touching the rest of the stack.
|
||||
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
|
||||
if !catalog_images.is_empty() {
|
||||
for (name, image) in stack_images.iter_mut() {
|
||||
if let Some(catalog_image) = catalog_images.get(name) {
|
||||
*image = catalog_image.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
stack_images
|
||||
}
|
||||
|
||||
/// Rollback: restart old containers if they still exist.
|
||||
|
||||
@@ -60,6 +60,30 @@ impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
// Serialize concurrent / retried generate calls. The web client aborts
|
||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
||||
// re-fires every 4s while the server is still booting on slow first-boot
|
||||
// hardware. Without this guard each hit would mint a brand-new seed and
|
||||
// overwrite the node keys mid-flight, leaving the words shown to the user
|
||||
// out of sync with what `seed.verify` expects — the classic "error at the
|
||||
// DID-creation screen". Holding the lock across the whole op fully
|
||||
// serializes them.
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
|
||||
// Idempotent fast-path: a fresh pending mnemonic already exists, so the
|
||||
// node keys are already on disk. Return the SAME words rather than
|
||||
// regenerating, so every retry yields a consistent result.
|
||||
if let Some(existing) = state.as_ref() {
|
||||
if existing.created_at.elapsed() < MNEMONIC_TTL {
|
||||
let words: Vec<String> = existing
|
||||
.words
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
return Ok(serde_json::json!({ "words": words }));
|
||||
}
|
||||
}
|
||||
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
@@ -89,16 +113,14 @@ impl RpcHandler {
|
||||
// the onboarding RPC returns immediately.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
let words: Vec<&str> = mnemonic.words().collect();
|
||||
let words: Vec<String> = mnemonic.words().map(str::to_string).collect();
|
||||
|
||||
// Hold mnemonic in memory for the verify step.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
// Hold mnemonic in memory for the verify step. We already own the lock
|
||||
// guard (`state`) from the top of the function, so just write through it.
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
@@ -149,11 +171,13 @@ impl RpcHandler {
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Clear mnemonic from memory now that it's verified.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = None;
|
||||
}
|
||||
// Intentionally DO NOT clear the mnemonic here. The web client aborts
|
||||
// slow requests at 15s and retries internally; if we wiped it on the
|
||||
// first (successful) verify, a retried request would fail with
|
||||
// "No pending seed generation or session expired" even though the user
|
||||
// did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min)
|
||||
// and is overwritten on the next generate, so leaving it makes verify
|
||||
// idempotent without meaningfully widening the in-memory window.
|
||||
|
||||
// Save the encrypted seed for convenience backup.
|
||||
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
|
||||
@@ -290,4 +314,101 @@ impl RpcHandler {
|
||||
"next_index": next_index,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the node's 24-word recovery phrase after onboarding. Heavily
|
||||
/// gated, because this is the keys to the whole node:
|
||||
/// - requires a full authenticated session (enforced upstream: this
|
||||
/// method is NOT in the public auth whitelist),
|
||||
/// - re-verifies the login password,
|
||||
/// - requires a valid TOTP code when 2FA is enabled (replay-protected),
|
||||
/// - decrypts `identity/master_seed.enc` with the backup passphrase
|
||||
/// (defaults to the login password when the user used the same value).
|
||||
/// The words are returned to the caller only and never logged.
|
||||
pub(in crate::api::rpc) async fn handle_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required to reveal the recovery phrase");
|
||||
}
|
||||
|
||||
// Nothing to reveal if this node never stored an encrypted seed.
|
||||
if !crate::seed::seed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"This node has no encrypted seed backup, so the recovery phrase \
|
||||
cannot be shown. It was only displayed once during setup."
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Re-authenticate with the login password.
|
||||
if !self.auth_manager.verify_password(&password).await? {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Incorrect password");
|
||||
}
|
||||
|
||||
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
|
||||
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if code.is_empty() {
|
||||
password.zeroize();
|
||||
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
|
||||
}
|
||||
let totp_data = self
|
||||
.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?;
|
||||
let secret = crate::totp::decrypt_secret(&totp_data, &password)
|
||||
.context("Could not unlock 2FA with this password")?;
|
||||
match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? {
|
||||
Some(step) => {
|
||||
// Record the used step for replay protection, pruning old ones.
|
||||
let mut data = totp_data;
|
||||
data.used_steps.push(step);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let cutoff = (now / 30) - 10; // ~5 minutes
|
||||
data.used_steps.retain(|s| *s > cutoff);
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
None => {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Invalid 2FA code");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
||||
// login password, so accept an explicit one and fall back to the
|
||||
// password when the user used the same value for both.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = reveal.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,64 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
|
||||
///
|
||||
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
|
||||
/// `cashuA` token denominated in one of those mints — paying directly if we
|
||||
/// already hold the right mint, else auto-swapping into a trusted accepted
|
||||
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
|
||||
/// wallet can't cover it, or the swap is too costly, returns `declined` so
|
||||
/// the caller falls back to the free origin (origin always wins).
|
||||
pub(super) async fn handle_streaming_prepare_payment(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let accepted_mints: Vec<String> = params
|
||||
.get("accepted_mints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let price_sats = params
|
||||
.get("price_sats")
|
||||
.or_else(|| params.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
||||
// Default budget = the asked price (willing to pay exactly what's quoted).
|
||||
let budget_sats = params
|
||||
.get("budget_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(price_sats);
|
||||
let max_fee_sats = params
|
||||
.get("max_fee_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
|
||||
match crate::swarm::payment::auto_pay_token(
|
||||
&self.config.data_dir,
|
||||
&policy,
|
||||
&accepted_mints,
|
||||
price_sats,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(token) => Ok(serde_json::json!({
|
||||
"status": "ready",
|
||||
"token": token,
|
||||
"paid_sats": price_sats,
|
||||
})),
|
||||
None => Ok(serde_json::json!({
|
||||
"status": "declined",
|
||||
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover available streaming services (pricing info).
|
||||
/// This is the unauthenticated discovery endpoint.
|
||||
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -253,6 +253,54 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Report the node's swarm prefs (fetch source + whether it provides to the
|
||||
/// swarm) plus swarm capability, so the UI can show whether DHT mode is
|
||||
/// actually usable on this build.
|
||||
pub(super) async fn handle_update_get_source(&self) -> Result<serde_json::Value> {
|
||||
let source = update::load_update_source(&self.config.data_dir).await;
|
||||
let provide_dht = update::load_provide_dht(&self.config.data_dir).await;
|
||||
let source_str = match source {
|
||||
update::UpdateSource::Origin => "origin",
|
||||
update::UpdateSource::Swarm => "swarm",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"source": source_str,
|
||||
// Whether this node seeds/serves blobs to peers (default true).
|
||||
"provide_dht": provide_dht,
|
||||
// Compiled with the iroh swarm engine? If false, "swarm" mode has no
|
||||
// peers and silently behaves like origin.
|
||||
"swarm_available": cfg!(feature = "iroh-swarm"),
|
||||
// Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED).
|
||||
"swarm_enabled": self.config.swarm_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update the node's swarm prefs. Params (both optional, at least one):
|
||||
/// `{ source?: "origin" | "swarm", provide?: bool }`.
|
||||
pub(super) async fn handle_update_set_source(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut touched = false;
|
||||
if let Some(s) = params.get("source").and_then(|v| v.as_str()) {
|
||||
let source = match s {
|
||||
"origin" => update::UpdateSource::Origin,
|
||||
"swarm" => update::UpdateSource::Swarm,
|
||||
_ => anyhow::bail!("source must be \"origin\" or \"swarm\""),
|
||||
};
|
||||
update::save_update_source(&self.config.data_dir, source).await?;
|
||||
touched = true;
|
||||
}
|
||||
if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) {
|
||||
update::save_provide_dht(&self.config.data_dir, provide).await?;
|
||||
touched = true;
|
||||
}
|
||||
if !touched {
|
||||
anyhow::bail!("expected \"source\" and/or \"provide\"");
|
||||
}
|
||||
self.handle_update_get_source().await
|
||||
}
|
||||
|
||||
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
|
||||
/// Duplicates (same URL) are replaced rather than added twice.
|
||||
pub(super) async fn handle_update_add_mirror(
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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?;
|
||||
@@ -129,11 +136,27 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobMeta {
|
||||
pub cid: String,
|
||||
/// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address).
|
||||
/// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this
|
||||
/// advertises the hash a peer swarm can fetch/range-verify by. Absent in
|
||||
/// legacy metadata written before Phase 1.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blake3: Option<String>,
|
||||
pub size: u64,
|
||||
pub mime: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -88,6 +94,7 @@ impl BlobStore {
|
||||
let cid = hex::encode(hasher.finalize());
|
||||
let meta = BlobMeta {
|
||||
cid: cid.clone(),
|
||||
blake3: Some(crate::content_hash::blake3_hex(bytes)),
|
||||
size: bytes.len() as u64,
|
||||
mime: mime.to_string(),
|
||||
filename,
|
||||
|
||||
@@ -30,8 +30,22 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s
|
||||
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
// Kiosk hardening (#36): keep the deployed unit + launcher in sync with the
|
||||
// repo so the CPU/memory cap and the GPU-vs-headless flag selection reach
|
||||
// already-installed nodes via OTA, not just fresh ISOs.
|
||||
const KIOSK_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-kiosk.service");
|
||||
const KIOSK_LAUNCHER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-kiosk-launcher.sh");
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
|
||||
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
|
||||
/// HTTP-only nodes, in which case the bootstrap loop skips it.
|
||||
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@@ -41,6 +55,41 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
|
||||
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
|
||||
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
|
||||
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
|
||||
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the peer-content streaming proxy.
|
||||
/// Without it, the browser's `<video>`/`<audio>` Range requests to
|
||||
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
|
||||
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
|
||||
/// disables buffering so streaming works. Kept in sync with the canonical
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
|
||||
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
|
||||
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
|
||||
/// nodes converge to the same config fresh ISOs ship with.
|
||||
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
|
||||
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
|
||||
/// at all (older than the main conf's), and the directive that follows it varies
|
||||
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
|
||||
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
|
||||
/// proxying there) and insert the reroot set right after it — directive order
|
||||
/// inside a location block is irrelevant to nginx. Idempotent via the same
|
||||
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -476,6 +525,92 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
|
||||
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
|
||||
|
||||
/// B17 self-heal: ensure the installed archipelago.service waits for the data
|
||||
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
|
||||
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
|
||||
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
|
||||
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
|
||||
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
|
||||
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
|
||||
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
|
||||
/// never restart the running service here. Idempotent; no-op if the unit is absent
|
||||
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
|
||||
/// the requirement to the always-mounted root).
|
||||
pub async fn ensure_archipelago_mount_ordering() {
|
||||
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"mount-ordering self-heal: {} not readable ({}) — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if current.contains(MOUNT_REQUIRE_LINE) {
|
||||
return; // already healed
|
||||
}
|
||||
// Insert the directive into the [Unit] section, immediately before [Service].
|
||||
let Some(idx) = current.find("\n[Service]") else {
|
||||
tracing::warn!(
|
||||
"mount-ordering self-heal: no [Service] section in {} — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
|
||||
patched.push_str(¤t[..idx]);
|
||||
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
|
||||
patched.push_str(MOUNT_REQUIRE_LINE);
|
||||
patched.push_str(¤t[idx..]);
|
||||
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"B17: added '{}' to archipelago.service (effective next reboot)",
|
||||
MOUNT_REQUIRE_LINE
|
||||
);
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// #36 self-heal: keep the kiosk unit + launcher current on already-deployed
|
||||
/// nodes so the CPU/memory cap (a runaway chromium was saturating the node and
|
||||
/// starving the backend) and the GPU-vs-headless flag selection arrive via OTA.
|
||||
/// No-op on nodes without the kiosk installed; only restarts the kiosk if it's
|
||||
/// actually running (so it never re-enables an operator-disabled kiosk).
|
||||
pub async fn ensure_kiosk_hardened() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // kiosk not installed on this node
|
||||
}
|
||||
let svc_changed = write_root_if_needed(KIOSK_SERVICE_PATH, KIOSK_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let launcher_changed = write_root_if_needed(KIOSK_LAUNCHER_PATH, KIOSK_LAUNCHER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if launcher_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", KIOSK_LAUNCHER_PATH]).await;
|
||||
}
|
||||
if svc_changed || launcher_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("kiosk hardening: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
// try-restart only restarts a currently-active unit — leaves a stopped/
|
||||
// disabled kiosk alone.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-kiosk.service"]).await;
|
||||
info!("kiosk: applied resource cap + GPU-flag hardening (#36)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -496,7 +631,11 @@ async fn run_nginx() -> Result<bool> {
|
||||
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
for path in [
|
||||
NGINX_CONF_PATH,
|
||||
NGINX_ENABLED_CONF_PATH,
|
||||
NGINX_HTTPS_SNIPPET_PATH,
|
||||
] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
@@ -514,18 +653,100 @@ async fn run_nginx() -> Result<bool> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Reflective CORS add_headers that older configs placed inside the
|
||||
/// `/lnd-connect-info` location. The backend now sets a validated
|
||||
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
|
||||
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
|
||||
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
|
||||
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
|
||||
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
// Each "missing" flag is gated on the splice anchor actually being present,
|
||||
// so an included snippet that legitimately has none of these endpoints (the
|
||||
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
|
||||
// every boot — it falls through to the fedimint heal alone.
|
||||
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|
||||
|| content.contains(" location /electrs-status {");
|
||||
let missing_app_catalog = content
|
||||
.contains(" # DWN endpoints — peer access over Tor (no auth)")
|
||||
&& !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = content.contains(" location /electrs-status {")
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
patched = patched.replace(NGINX_LND_DUP_CORS, "");
|
||||
}
|
||||
|
||||
if needs_fedimint_css {
|
||||
// Style A (main conf): the block already injects nostr-provider, so swap
|
||||
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
|
||||
// node's fedimint block doesn't match OLD.
|
||||
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
|
||||
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
|
||||
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
|
||||
// the marker so it can never double-apply after Style A already healed.
|
||||
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
|
||||
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_lnd_proxy {
|
||||
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
|
||||
// back to `/electrs-status` (since 2026-03-08) for even older configs.
|
||||
// Both appear once per archipelago server block, so the block is added
|
||||
// to every server block that proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Release-root signing ceremony — the publisher-side counterpart to
|
||||
//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the
|
||||
//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical
|
||||
//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against.
|
||||
//!
|
||||
//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or
|
||||
//! stdin — never an argv so it stays out of shell history / `ps`):
|
||||
//!
|
||||
//! ```text
|
||||
//! archipelago ceremony gen
|
||||
//! Generate a fresh 24-word release master mnemonic and print it plus the
|
||||
//! derived release-root pubkey + did. Back the mnemonic up OFFLINE.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey
|
||||
//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY /
|
||||
//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign <file.json>
|
||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||
//! what `trust::verify_detached` recomputes on every node.
|
||||
//! ```
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
use crate::seed::{self, MasterSeed};
|
||||
use crate::trust::{did, signed_doc};
|
||||
|
||||
const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC";
|
||||
|
||||
/// True if argv selects the ceremony subcommand. Checked before any server init.
|
||||
pub fn is_ceremony_invocation() -> bool {
|
||||
std::env::args().nth(1).as_deref() == Some("ceremony")
|
||||
}
|
||||
|
||||
/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the
|
||||
/// caller (main) should exit without starting the server.
|
||||
pub fn run() -> Result<()> {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"gen" => cmd_gen(),
|
||||
"pubkey" => cmd_pubkey(),
|
||||
"sign" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||
cmd_sign(&file)
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
|
||||
other
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_gen() -> Result<()> {
|
||||
let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?;
|
||||
let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?;
|
||||
eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive");
|
||||
eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n");
|
||||
println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic);
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_pubkey() -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_sign(path: &str) -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
|
||||
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||
{
|
||||
let obj = value
|
||||
.as_object_mut()
|
||||
.context("document root must be a JSON object")?;
|
||||
// Re-sign cleanly: drop any prior signature so the preimage matches.
|
||||
obj.remove("signature");
|
||||
obj.remove("signed_by");
|
||||
}
|
||||
|
||||
let (signature, signed_by) =
|
||||
signed_doc::sign_detached(&key, &value).context("sign document")?;
|
||||
|
||||
let obj = value.as_object_mut().expect("checked above");
|
||||
obj.insert("signature".into(), serde_json::Value::String(signature));
|
||||
obj.insert(
|
||||
"signed_by".into(),
|
||||
serde_json::Value::String(signed_by.clone()),
|
||||
);
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
||||
|
||||
eprintln!("✓ signed {path}");
|
||||
eprintln!(" signed_by: {signed_by}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||
fn load_release_root_key() -> Result<SigningKey> {
|
||||
let phrase = read_mnemonic()?;
|
||||
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
|
||||
.context("invalid release master mnemonic")?;
|
||||
seed::derive_release_root_ed25519(&seed).context("derive release-root")
|
||||
}
|
||||
|
||||
/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin.
|
||||
fn read_mnemonic() -> Result<String> {
|
||||
if let Ok(v) = std::env::var(ENV_MNEMONIC) {
|
||||
if !v.trim().is_empty() {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
use std::io::Read;
|
||||
eprintln!("Paste the release master mnemonic, then Ctrl-D:");
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.context("read mnemonic from stdin")?;
|
||||
if buf.trim().is_empty() {
|
||||
bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)");
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn print_key(key: &SigningKey) {
|
||||
let vk = key.verifying_key();
|
||||
println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes()));
|
||||
println!("signed_by_did={}", did::did_key_for_ed25519(&vk));
|
||||
}
|
||||
@@ -70,6 +70,13 @@ pub struct Config {
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
/// DHT swarm-assist (Phase 3): when true AND the binary was built with the
|
||||
/// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release
|
||||
/// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr
|
||||
/// adverts. Off by default; with the feature absent this is inert. Reuses
|
||||
/// `nostr_relays` + `nostr_tor_proxy` for discovery transport.
|
||||
#[serde(default)]
|
||||
pub swarm_enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -182,6 +189,12 @@ impl Config {
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary
|
||||
// was also built with the `iroh-swarm` feature; otherwise inert.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") {
|
||||
config.swarm_enabled = parse_truthy_env(&v);
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
@@ -241,6 +254,7 @@ impl Default for Config {
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
swarm_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA.
|
||||
//!
|
||||
//! Background: `image_versions.rs` reads the pinned image tags from
|
||||
//! `image-versions.sh`, which is deployed *with the archipelago binary*. That
|
||||
//! coupled every app update to a full node release. This module adds a remote
|
||||
//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the
|
||||
//! OTA manifest, refreshed periodically and on demand. Bumping an app's version
|
||||
//! is then a JSON edit + push — no binary release.
|
||||
//!
|
||||
//! Resolution order (origin-always-wins, matching the DHT design's posture):
|
||||
//! 1. Remote catalog (this module) — the live source of "available update".
|
||||
//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is
|
||||
//! missing or doesn't cover the app.
|
||||
//!
|
||||
//! ## Forward-compatibility with the DHT distribution plan
|
||||
//! (`docs/dht-distribution-design.md`)
|
||||
//! This catalog IS the "discovery / authenticity" layer of that plan. The schema
|
||||
//! is deliberately extensible so the later phases bolt on WITHOUT a breaking
|
||||
//! change:
|
||||
//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root
|
||||
//! signature over the canonical JSON. Absent today; verified when present.
|
||||
//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so
|
||||
//! the iroh swarm can fetch images by hash with the registry as origin.
|
||||
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
|
||||
//! publisher side never breaks older nodes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Filename for both the published catalog and the on-node cache.
|
||||
pub const APP_CATALOG_FILE: &str = "app-catalog.json";
|
||||
|
||||
/// Cache of the parsed catalog, invalidated when the cache file mtime changes.
|
||||
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: SystemTime,
|
||||
catalog: AppCatalog,
|
||||
}
|
||||
|
||||
/// Top-level catalog document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalog {
|
||||
/// Schema version. 1 = current. Bump only on incompatible changes.
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
/// Publish date (RFC 3339 or YYYY-MM-DD). Informational.
|
||||
#[serde(default)]
|
||||
pub updated: String,
|
||||
/// app_id -> entry.
|
||||
#[serde(default)]
|
||||
pub apps: HashMap<String, AppCatalogEntry>,
|
||||
/// DHT-plan forward-compat: detached signature over the canonical JSON,
|
||||
/// produced by the seed-derived release-root key. Absent today.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
/// DHT-plan forward-compat: publisher identity (did:key / npub).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-app catalog entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalogEntry {
|
||||
/// User-facing version string (drives the "Update available" badge text).
|
||||
pub version: String,
|
||||
/// Primary single-container image reference (`registry/repo:tag`). For stack
|
||||
/// apps this is the primary container's image (the one whose version the
|
||||
/// badge tracks — e.g. the IndeeHub frontend).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Stack apps only: container_name -> image reference. Components omitted here
|
||||
/// fall back to the `image-versions.sh` pin during an update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<HashMap<String, String>>,
|
||||
/// DHT-plan forward-compat: content address of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||
/// daemon's data dir first (via env for dev), then the canonical runtime path.
|
||||
fn cache_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
paths.push(Path::new(&dir).join(APP_CATALOG_FILE));
|
||||
}
|
||||
paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE));
|
||||
paths
|
||||
}
|
||||
|
||||
fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
|
||||
for p in cache_paths() {
|
||||
if let Ok(meta) = p.metadata() {
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
return Some((p, mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load and cache the on-node catalog. Returns an empty catalog when absent —
|
||||
/// callers then fall back to `image-versions.sh`.
|
||||
fn load_catalog() -> AppCatalog {
|
||||
let (path, mtime) = match find_cache_file() {
|
||||
Some(v) => v,
|
||||
None => return AppCatalog::default(),
|
||||
};
|
||||
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(ref entry) = *cache {
|
||||
if entry.mtime == mtime {
|
||||
return entry.catalog.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("app-catalog: failed to read {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
let catalog: AppCatalog = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("app-catalog: invalid JSON at {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
catalog: catalog.clone(),
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
/// Primary image for an app per the remote catalog, if covered.
|
||||
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).and_then(|e| e.image)
|
||||
}
|
||||
|
||||
/// Per-container stack image overrides from the catalog (container_name -> image).
|
||||
pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// never redirect an app to an unrelated image. `None` means "use the manifest
|
||||
/// image as-is" (catalog absent, app uncovered, or repo mismatch).
|
||||
pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<String> {
|
||||
let candidate = catalog_primary_image(app_id)?;
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})",
|
||||
app_id, candidate, manifest_image
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoupled "available update" check for ALL apps.
|
||||
///
|
||||
/// Prefers the remote catalog; when the catalog covers the app, its verdict is
|
||||
/// authoritative (so we never advertise a stale `image-versions.sh` pin over a
|
||||
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||
/// catalog is missing or doesn't cover the app.
|
||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
}
|
||||
// Not covered by the catalog -> baseline pin from image-versions.sh.
|
||||
crate::container::image_versions::available_update_for_app(app_id, running_image)
|
||||
}
|
||||
|
||||
/// Derive candidate catalog URLs from the OTA mirror list by swapping the
|
||||
/// manifest filename for the catalog filename. Falls back to the default
|
||||
/// manifest origin when no mirrors are configured.
|
||||
fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<String> {
|
||||
let mut urls: Vec<String> = mirrors
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
// mirror.url ends with ".../releases/manifest.json"
|
||||
if m.url.ends_with("manifest.json") {
|
||||
Some(m.url.replace("manifest.json", APP_CATALOG_FILE))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
|
||||
/// Fetch the catalog from the first reachable mirror and atomically write it to
|
||||
/// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
|
||||
/// success. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<usize> {
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
if urls.is_empty() {
|
||||
debug!("app-catalog: no mirror-derived URLs to fetch from");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for url in &urls {
|
||||
match fetch_one(&client, url).await {
|
||||
Ok(catalog) => {
|
||||
let count = catalog.apps.len();
|
||||
write_cache(data_dir, &catalog)?;
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
info!("app-catalog: refreshed from {} ({} apps)", url, count);
|
||||
return Ok(count);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("app-catalog: fetch {} failed: {}", url, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
let body = resp.text().await?;
|
||||
let catalog: AppCatalog = serde_json::from_str(&body)?;
|
||||
|
||||
// DHT Phase 0 authenticity: verify the release-root signature when present.
|
||||
// We verify against the raw JSON (the exact bytes the publisher signed),
|
||||
// not a re-serialization of the typed struct, so unknown forward-compat
|
||||
// fields stay part of the signed preimage. Unsigned catalogs are still
|
||||
// accepted during the migration window — same trust level as today's
|
||||
// manifest — but a *present* signature that fails is a hard reject so a
|
||||
// tampering mirror cannot pass off altered bytes.
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
match crate::trust::verify_detached(&raw)? {
|
||||
crate::trust::SignatureStatus::Unsigned => {
|
||||
debug!("app-catalog: unsigned (accepted during migration window)");
|
||||
}
|
||||
crate::trust::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored,
|
||||
} => {
|
||||
if anchored {
|
||||
info!(
|
||||
"app-catalog: release-root signature verified ({})",
|
||||
signer_did
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: signature self-consistent but release-root anchor \
|
||||
not pinned ({}); cannot confirm signer identity",
|
||||
signer_did
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
|
||||
let dest = data_dir.join(APP_CATALOG_FILE);
|
||||
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"updated": "2026-06-16",
|
||||
"future_field": "ignored",
|
||||
"signature": "sig123",
|
||||
"signed_by": "did:key:zABC",
|
||||
"apps": {
|
||||
"indeedhub": {
|
||||
"version": "1.0.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
|
||||
"digest": "blake3:deadbeef",
|
||||
"size": 12345,
|
||||
"another_future_field": true
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cat.schema, 1);
|
||||
assert_eq!(cat.signature.as_deref(), Some("sig123"));
|
||||
let e = cat.apps.get("indeedhub").unwrap();
|
||||
assert_eq!(e.version, "1.0.1");
|
||||
assert_eq!(
|
||||
e.image.as_deref(),
|
||||
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
|
||||
);
|
||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_when_absent_is_default() {
|
||||
let cat = AppCatalog::default();
|
||||
assert!(cat.apps.is_empty());
|
||||
assert!(cat.signature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_url_derived_from_mirror() {
|
||||
let mirrors = vec![crate::update::UpdateMirror {
|
||||
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 1".to_string(),
|
||||
}];
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -439,12 +446,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]
|
||||
|
||||
@@ -172,8 +172,10 @@ impl DockerPackageScanner {
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
|
||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||
let available_update =
|
||||
image_versions::available_update_for_app(&app_id, &container.image);
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
@@ -363,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(),
|
||||
|
||||
@@ -213,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
available_update_for_images(&pinned, running_image)
|
||||
}
|
||||
|
||||
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
@@ -255,7 +255,7 @@ fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
pub fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
let without_tag = strip_tag(image);
|
||||
match without_tag.split_once('/') {
|
||||
Some((first, rest))
|
||||
|
||||
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
/// Per-node LND wallet password file (random, 0600). Replaces the old
|
||||
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
|
||||
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
|
||||
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
|
||||
|
||||
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
|
||||
/// unlock fallback so wallets created by those builds still open; new wallets
|
||||
/// never use it, and the login-path migration rotates away from it.
|
||||
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
@@ -79,15 +88,125 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -121,11 +240,96 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
/// Read the per-node wallet password from the secrets file, if present.
|
||||
/// Never generates one — absence means "fall back to legacy / not set yet".
|
||||
async fn read_wallet_password() -> Option<String> {
|
||||
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
|
||||
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
|
||||
(!pw.is_empty()).then_some(pw)
|
||||
}
|
||||
|
||||
/// Return the per-node wallet password, generating and persisting a fresh
|
||||
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
|
||||
/// wallet — calling it merely to unlock an existing wallet would record a
|
||||
/// password that doesn't match it.
|
||||
pub(crate) async fn ensure_wallet_password() -> Result<String> {
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
return Ok(pw);
|
||||
}
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut raw);
|
||||
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
fs::write(path, &pw)
|
||||
.await
|
||||
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
|
||||
/// per-node secret (current scheme) first, then the legacy constant so wallets
|
||||
/// created by older builds still open.
|
||||
async fn unlock_password_candidates() -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
v.push(pw);
|
||||
}
|
||||
v.push(LEGACY_WALLET_PASSWORD.to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
|
||||
/// password (no point retrying) vs keep waiting for LND to come up.
|
||||
enum UnlockAttempt {
|
||||
Unlocked,
|
||||
WrongPassword,
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
|
||||
/// (WrongPassword — try the next candidate, don't retry) from transient
|
||||
/// not-ready / connection errors (NotReady — worth retrying).
|
||||
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
|
||||
let body = serde_json::json!({
|
||||
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() || text.contains("already unlocked") {
|
||||
UnlockAttempt::Unlocked
|
||||
} else if text.contains("invalid passphrase") {
|
||||
UnlockAttempt::WrongPassword
|
||||
} else {
|
||||
UnlockAttempt::NotReady
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockAttempt::NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
|
||||
/// password was actively rejected (unrecoverable — caller should recreate);
|
||||
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
|
||||
async fn unlock_existing_wallet() -> Result<bool> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
@@ -133,57 +337,130 @@ async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
let candidates = unlock_password_candidates().await;
|
||||
// Retry only while LND's unlocker isn't ready yet. If every candidate is
|
||||
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
|
||||
// with a clear message instead of hanging the boot path for 60s+ (the wallet
|
||||
// was created with a password this node doesn't have → migration/recovery).
|
||||
for _ in 0..60 {
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
UnlockAttempt::Unlocked => return Ok(true),
|
||||
UnlockAttempt::WrongPassword => {}
|
||||
UnlockAttempt::NotReady => all_rejected = false,
|
||||
}
|
||||
}
|
||||
if all_rejected {
|
||||
tracing::warn!(
|
||||
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
|
||||
with a password this node does not have",
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/state"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("state")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
|
||||
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
|
||||
/// rejected (wrong password — try the next candidate); Err = transport/other.
|
||||
async fn change_wallet_password(
|
||||
client: &reqwest::Client,
|
||||
current: &str,
|
||||
new: &str,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
|
||||
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling LND changepassword")?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else if text.contains("invalid passphrase") {
|
||||
Ok(false)
|
||||
} else {
|
||||
anyhow::bail!("LND changepassword returned {status}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
|
||||
/// login, when the onboarding password is available as a candidate. If the
|
||||
/// per-node secret already opens the wallet, just unlock. Otherwise try each
|
||||
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
|
||||
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
|
||||
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
|
||||
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only act on a wallet that is actually LOCKED.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
|
||||
// If the per-node secret already opens it, nothing to rotate — just unlock.
|
||||
if let Some(secret) = read_wallet_password().await {
|
||||
if matches!(
|
||||
try_unlock_once(&client, &secret).await,
|
||||
UnlockAttempt::Unlocked
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet's new password becomes the per-node secret (generate if absent).
|
||||
let new_secret = ensure_wallet_password().await?;
|
||||
|
||||
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for cand in candidates {
|
||||
if cand.is_empty() || *cand == new_secret {
|
||||
continue;
|
||||
}
|
||||
match change_wallet_password(&client, cand, &new_secret).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) => continue, // wrong current password — try next candidate
|
||||
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -225,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let wallet_password =
|
||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
@@ -239,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -450,7 +730,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
fn legacy_wallet_password_is_valid_for_lncli() {
|
||||
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
|
||||
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_candidates_always_include_legacy_fallback() {
|
||||
// With no per-node secret on disk in the test env, candidates fall back
|
||||
// to the legacy constant so old wallets still open.
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod app_catalog;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
|
||||
@@ -70,6 +70,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 +180,70 @@ pub fn compute_container_name(manifest: &AppManifest) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// hashes each file's relative path, length, and mtime.
|
||||
///
|
||||
/// Metadata-only by design: it's cheap enough to recompute on every reconcile,
|
||||
/// and podman's own COPY-layer cache still skips the actual layer work when the
|
||||
/// file *content* is unchanged, so a spurious mtime bump costs almost nothing.
|
||||
/// Returns `None` if the context can't be read (caller falls back to building).
|
||||
fn fingerprint_build_context(context: &Path) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut entries: Vec<(String, u64, i128)> = Vec::new();
|
||||
let mut stack = vec![context.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = std::fs::read_dir(&dir).ok()?;
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(context)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or(0);
|
||||
entries.push((rel, meta.len(), mtime));
|
||||
}
|
||||
}
|
||||
// Sort so the hash is independent of directory-walk order.
|
||||
entries.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for (rel, len, mtime) in &entries {
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(len.to_le_bytes());
|
||||
hasher.update(mtime.to_le_bytes());
|
||||
}
|
||||
Some(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Path of the stamp file recording the build-context fingerprint that produced
|
||||
/// the currently-built image for `tag`. Keyed by a filesystem-safe form of the
|
||||
/// tag so distinct UI images don't collide.
|
||||
fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
||||
let safe: String = tag
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
data_dir
|
||||
.join(".image-build")
|
||||
.join(format!("{safe}.fingerprint"))
|
||||
}
|
||||
|
||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
let uid = uid_gid
|
||||
.split_once(':')
|
||||
@@ -219,6 +287,120 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
/// For each writable bind mount, write-probe as the container's own process
|
||||
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
|
||||
/// as root) to that service uid:gid. Because the chown runs in the container's
|
||||
/// user namespace, podman translates it to the correct host owner regardless of
|
||||
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
|
||||
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
|
||||
/// This is the durable replacement for the per-app hardcoded host chowns.
|
||||
///
|
||||
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
|
||||
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
|
||||
/// returns true if it repaired something; never fails reconcile (a degraded app
|
||||
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
|
||||
async fn ensure_running_container_ownership(name: &str) -> bool {
|
||||
async fn podman_stdout(args: &[&str]) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
// The uid:gid the container's main process actually runs as.
|
||||
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false, // can't exec (no shell / not running) — nothing to do
|
||||
};
|
||||
let gid = podman_stdout(&["exec", name, "id", "-g"])
|
||||
.await
|
||||
.filter(|g| !g.is_empty())
|
||||
.unwrap_or_else(|| uid.clone());
|
||||
|
||||
// Writable bind-mount destinations only.
|
||||
let dests = match podman_stdout(&[
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let mut repaired = false;
|
||||
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
// Never touch system / socket bind mounts.
|
||||
if dest == "/"
|
||||
|| dest.starts_with("/proc")
|
||||
|| dest.starts_with("/sys")
|
||||
|| dest.starts_with("/dev")
|
||||
|| dest.starts_with("/run")
|
||||
|| dest.starts_with("/etc")
|
||||
|| dest.ends_with(".sock")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drift check: can the service user write here already?
|
||||
let probe = format!(
|
||||
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
|
||||
);
|
||||
let writable = tokio::process::Command::new("podman")
|
||||
.args(["exec", name, "sh", "-c", &probe])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if writable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Repair inside the container's userns — podman maps to the right host uid.
|
||||
let chown = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"-u",
|
||||
"0",
|
||||
name,
|
||||
"chown",
|
||||
"-R",
|
||||
&format!("{uid}:{gid}"),
|
||||
dest,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
match chown {
|
||||
Ok(o) if o.status.success() => {
|
||||
repaired = true;
|
||||
tracing::warn!(
|
||||
container = %name, dest, uid = %uid,
|
||||
"repaired unwritable volume ownership (in-container chown)"
|
||||
);
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
container = %name, dest,
|
||||
"volume ownership repair failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
repaired
|
||||
}
|
||||
|
||||
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
@@ -772,6 +954,8 @@ pub struct ProdContainerOrchestrator {
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: Option<String>,
|
||||
}
|
||||
|
||||
struct FileSecretsProvider {
|
||||
@@ -832,6 +1016,8 @@ impl ProdContainerOrchestrator {
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,6 +1036,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,6 +1275,30 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
|
||||
// (registry/manifest apps AND legacy compose stacks like immich) and
|
||||
// repair any that can't write their bind mounts — the durable, app-
|
||||
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
|
||||
// so steady state is just cheap in-container write-probes; only a broken
|
||||
// volume is chowned (in-userns, mapping-proof) and its container
|
||||
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
|
||||
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
|
||||
if let Ok(containers) = self.runtime.list_containers().await {
|
||||
for c in containers
|
||||
.iter()
|
||||
.filter(|c| matches!(c.state, ContainerState::Running))
|
||||
{
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &c.name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
@@ -1113,6 +1324,33 @@ impl ProdContainerOrchestrator {
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
self.ensure_app_secrets(&app_id).await?;
|
||||
|
||||
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
|
||||
// bitcoin-knots share port 8332, so if the *other* variant is already
|
||||
// running the inactive one can never start — the reconciler would just
|
||||
// churn "address already in use" and report a reconcile failure. Skip
|
||||
// it, mirroring the health monitor's same skip. (#47)
|
||||
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
|
||||
"bitcoin-core" => Some("bitcoin-knots"),
|
||||
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
|
||||
_ => None,
|
||||
} {
|
||||
if let Ok(list) = self.runtime.list_containers().await {
|
||||
let other_running = list.iter().any(|c| {
|
||||
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
|
||||
&& matches!(c.state, ContainerState::Running)
|
||||
});
|
||||
if other_running {
|
||||
tracing::debug!(
|
||||
app_id = %app_id,
|
||||
conflict,
|
||||
"skipping reconcile — the other Bitcoin implementation is running"
|
||||
);
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
@@ -1380,7 +1618,29 @@ impl ProdContainerOrchestrator {
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
|
||||
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
|
||||
// Decouple the app image from the shipped manifest: prefer the remote
|
||||
// app catalog when it covers this app with a same-repo image. This makes
|
||||
// both the pull below and create_container() below use the catalog tag,
|
||||
// so an app update no longer requires a binary/runtime release. Falls
|
||||
// back to the manifest image when the catalog is absent/uncovered.
|
||||
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
||||
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
|
||||
&resolved_manifest.app.id,
|
||||
¤t,
|
||||
) {
|
||||
if catalog_image != current {
|
||||
tracing::info!(
|
||||
app_id = %resolved_manifest.app.id,
|
||||
from = %current,
|
||||
to = %catalog_image,
|
||||
"app-catalog: overriding manifest image"
|
||||
);
|
||||
resolved_manifest.app.container.image = Some(catalog_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"manifest for {} has invalid container source (neither image nor build)",
|
||||
lm.manifest.app.id
|
||||
@@ -1408,7 +1668,7 @@ impl ProdContainerOrchestrator {
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
}
|
||||
let already = match self.runtime.image_exists(&bcfg.tag).await {
|
||||
let exists = match self.runtime.image_exists(&bcfg.tag).await {
|
||||
Ok(exists) => exists,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
@@ -1419,11 +1679,51 @@ impl ProdContainerOrchestrator {
|
||||
false
|
||||
}
|
||||
};
|
||||
if !already {
|
||||
// Presence alone isn't enough: the local UI images (bitcoin-ui,
|
||||
// lnd-ui, electrs-ui) COPY a built `neode-ui` dist, so a UI
|
||||
// update changes the source but leaves the old tag in place.
|
||||
// Rebuild whenever the build context's fingerprint differs from
|
||||
// the one that produced the existing image (#34). podman's
|
||||
// COPY-layer cache keeps the rebuild cheap when content is
|
||||
// actually unchanged.
|
||||
let fingerprint = fingerprint_build_context(Path::new(&bcfg.context));
|
||||
let stamp_path = build_fingerprint_stamp_path(&self.data_dir, &bcfg.tag);
|
||||
let stale = match &fingerprint {
|
||||
Some(current) => match tokio::fs::read_to_string(&stamp_path).await {
|
||||
Ok(prev) => prev.trim() != current,
|
||||
// No stamp recorded → treat as stale so we rebuild and
|
||||
// capture the fingerprint going forward.
|
||||
Err(_) => true,
|
||||
},
|
||||
// Couldn't fingerprint the context — don't skip on staleness.
|
||||
None => true,
|
||||
};
|
||||
if !exists || stale {
|
||||
if exists && stale {
|
||||
tracing::info!(
|
||||
image = %bcfg.tag,
|
||||
context = %bcfg.context,
|
||||
"build context changed since last build; rebuilding image"
|
||||
);
|
||||
}
|
||||
self.runtime
|
||||
.build_image(&bcfg)
|
||||
.await
|
||||
.with_context(|| format!("build_image {}", bcfg.tag))?;
|
||||
// Record the fingerprint that this image was built from so
|
||||
// the next reconcile skips the build until the source moves.
|
||||
if let Some(current) = &fingerprint {
|
||||
if let Some(parent) = stamp_path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(err) = tokio::fs::write(&stamp_path, current).await {
|
||||
tracing::warn!(
|
||||
image = %bcfg.tag,
|
||||
error = %err,
|
||||
"failed to write build fingerprint stamp"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2314,9 +2614,45 @@ impl ProdContainerOrchestrator {
|
||||
host_ip,
|
||||
host_mdns,
|
||||
disk_gb,
|
||||
// Cheap default; resolve_dynamic_env fills the real node name on
|
||||
// demand (it costs a podman call) only for manifests that use
|
||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||
/// Synchronous `podman ps` to match the surrounding host-fact detection;
|
||||
/// defaults to `bitcoin-knots` when none is running (B12).
|
||||
fn bitcoin_host(&self) -> String {
|
||||
#[cfg(test)]
|
||||
if let Some(host) = &self.test_bitcoin_host {
|
||||
return host.clone();
|
||||
}
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
|
||||
self.test_bitcoin_host = Some(host.to_string());
|
||||
}
|
||||
|
||||
fn detect_host_ip() -> Option<String> {
|
||||
let output = Command::new("hostname").arg("-I").output().ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -2396,11 +2732,29 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
.context("ensuring bitcoin tx-relay credentials")?;
|
||||
}
|
||||
if app_id == "fedimint-clientd" {
|
||||
// The fmcd container's secret_env (fmcd-password) and the wallet
|
||||
// bridge both read this; generate it before secret_env resolves.
|
||||
crate::wallet::fedimint_client::ensure_fmcd_password(&self.secrets_dir)
|
||||
.await
|
||||
.context("ensuring fmcd password secret")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
let facts = self.detect_host_facts();
|
||||
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).
|
||||
if manifest
|
||||
.app
|
||||
.container
|
||||
.derived_env
|
||||
.iter()
|
||||
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
|
||||
{
|
||||
facts.bitcoin_host = self.bitcoin_host();
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
@@ -3489,6 +3843,35 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_core_rpc_host_follows_bitcoin_node() {
|
||||
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node
|
||||
// container is running (Knots OR Core), not a hardcoded value.
|
||||
let yaml = "app:\n id: mempool-api\n name: mempool-api\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: CORE_RPC_HOST\n template: \"{{BITCOIN_HOST}}\"\n";
|
||||
|
||||
for (node, expected) in [
|
||||
("bitcoin-core", "bitcoin-core"),
|
||||
("bitcoin-knots", "bitcoin-knots"),
|
||||
] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
orch.set_bitcoin_host_for_test(node);
|
||||
|
||||
let mut manifest = AppManifest::parse(yaml).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).unwrap();
|
||||
|
||||
assert!(
|
||||
manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e == &format!("CORE_RPC_HOST={expected}")),
|
||||
"node={node}: expected CORE_RPC_HOST={expected}, got {:?}",
|
||||
manifest.app.environment
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -4253,4 +4636,37 @@ app:
|
||||
let calls = rt.calls();
|
||||
assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_build_context_detects_source_changes() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let ctx = tmp.path();
|
||||
std::fs::write(ctx.join("Dockerfile"), "FROM nginx\n").unwrap();
|
||||
std::fs::create_dir_all(ctx.join("assets")).unwrap();
|
||||
std::fs::write(ctx.join("assets/app.js"), b"v1").unwrap();
|
||||
|
||||
let a = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
// Recomputing over the same tree is stable.
|
||||
let b = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
assert_eq!(a, b, "fingerprint must be stable for an unchanged tree");
|
||||
|
||||
// Changing a COPYed source file (different length) changes the fingerprint.
|
||||
std::fs::write(ctx.join("assets/app.js"), b"v2-longer").unwrap();
|
||||
let c = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
assert_ne!(a, c, "changed source file must change the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fingerprint_stamp_path_sanitizes_tag() {
|
||||
let p = build_fingerprint_stamp_path(
|
||||
Path::new("/var/lib/archipelago"),
|
||||
"localhost/bitcoin-ui:local",
|
||||
);
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from(
|
||||
"/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Content hashing for the DHT distribution plan's *integrity & addressing*
|
||||
//! tier (`docs/dht-distribution-design.md` §4).
|
||||
//!
|
||||
//! SHA-256 is the incumbent: it keys `blobs.rs` and verifies OTA components
|
||||
//! today. BLAKE3 is introduced **alongside** it because iroh-blobs addresses
|
||||
//! and *range-verifies* content by BLAKE3 — essential for resumable downloads
|
||||
//! and HLS streaming. During the migration window both may be present; SHA-256
|
||||
//! stays mandatory and BLAKE3 is verified when supplied.
|
||||
//!
|
||||
//! Digests are written multihash-style as `"<alg>:<hex>"`, e.g.
|
||||
//! `"blake3:ab12…"` / `"sha256:cd34…"`, matching the app-catalog `digest` field.
|
||||
//! Both algorithms emit 32-byte (64-hex-char) digests.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const DIGEST_LEN: usize = 32;
|
||||
|
||||
/// Supported content-hash algorithms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HashAlg {
|
||||
Sha256,
|
||||
Blake3,
|
||||
}
|
||||
|
||||
impl HashAlg {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
HashAlg::Sha256 => "sha256",
|
||||
HashAlg::Blake3 => "blake3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex-encoded SHA-256 of `bytes`.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// Hex-encoded BLAKE3 of `bytes`.
|
||||
pub fn blake3_hex(bytes: &[u8]) -> String {
|
||||
blake3::hash(bytes).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// A parsed `"<alg>:<hex>"` content digest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContentDigest {
|
||||
pub alg: HashAlg,
|
||||
/// Lowercase hex, validated to the algorithm's length.
|
||||
pub hex: String,
|
||||
}
|
||||
|
||||
impl ContentDigest {
|
||||
/// Parse a multihash-style `"<alg>:<hex>"` string.
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
let (alg_part, hex_part) = s
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow!("digest must be '<alg>:<hex>', got: {}", s))?;
|
||||
let alg = match alg_part {
|
||||
"sha256" => HashAlg::Sha256,
|
||||
"blake3" => HashAlg::Blake3,
|
||||
other => bail!("unsupported hash algorithm: {}", other),
|
||||
};
|
||||
let raw = hex::decode(hex_part).context("digest hex is invalid")?;
|
||||
if raw.len() != DIGEST_LEN {
|
||||
bail!(
|
||||
"{} digest must be {} bytes, got {}",
|
||||
alg.as_str(),
|
||||
DIGEST_LEN,
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
alg,
|
||||
hex: hex_part.to_ascii_lowercase(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the digest of `bytes` under this digest's algorithm.
|
||||
pub fn compute_hex(&self, bytes: &[u8]) -> String {
|
||||
match self.alg {
|
||||
HashAlg::Sha256 => sha256_hex(bytes),
|
||||
HashAlg::Blake3 => blake3_hex(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify `bytes` hash to this digest. Errors (does not panic) on mismatch.
|
||||
pub fn verify(&self, bytes: &[u8]) -> Result<()> {
|
||||
let actual = self.compute_hex(bytes);
|
||||
if actual.eq_ignore_ascii_case(&self.hex) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"{} mismatch: expected {}, got {}",
|
||||
self.alg.as_str(),
|
||||
self.hex,
|
||||
actual
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentDigest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.alg.as_str(), self.hex)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn digest_lengths_are_32_bytes() {
|
||||
assert_eq!(sha256_hex(b"hi").len(), 64);
|
||||
assert_eq!(blake3_hex(b"hi").len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blake3_known_answer() {
|
||||
// BLAKE3 of the empty input — RFC/reference vector.
|
||||
assert_eq!(
|
||||
blake3_hex(b""),
|
||||
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_roundtrip() {
|
||||
let d = ContentDigest::parse(&format!("blake3:{}", blake3_hex(b"x"))).unwrap();
|
||||
assert_eq!(d.alg, HashAlg::Blake3);
|
||||
assert_eq!(d.to_string(), format!("blake3:{}", blake3_hex(b"x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_and_rejects() {
|
||||
let d = ContentDigest::parse(&format!("sha256:{}", sha256_hex(b"payload"))).unwrap();
|
||||
assert!(d.verify(b"payload").is_ok());
|
||||
assert!(d.verify(b"tampered").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_input() {
|
||||
assert!(ContentDigest::parse("nocolon").is_err());
|
||||
assert!(ContentDigest::parse("md5:abcd").is_err());
|
||||
assert!(ContentDigest::parse("blake3:nothex").is_err());
|
||||
assert!(ContentDigest::parse("blake3:ab").is_err()); // too short
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||
//!
|
||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||
//! then releases the file to anyone presenting that payment hash.
|
||||
//!
|
||||
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||
//! because an unpaid invoice represents no money.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||
/// to pay an invoice and download, short enough to keep the map small.
|
||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entitlement {
|
||||
content_id: String,
|
||||
price_sats: u64,
|
||||
paid: bool,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Drop expired entries. Caller must hold the lock.
|
||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||
}
|
||||
|
||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.insert(
|
||||
payment_hash.to_string(),
|
||||
Entitlement {
|
||||
content_id: content_id.to_string(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||
pub async fn mark_paid(payment_hash: &str) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
if let Some(e) = map.get_mut(payment_hash) {
|
||||
e.paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The content_id + price an entitlement was issued for, if still live.
|
||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||
}
|
||||
|
||||
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||
/// This is the gate the content server consults to release a file.
|
||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| e.paid && e.content_id == content_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -198,6 +198,7 @@ pub async fn serve_content(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
) -> Result<ServeResult> {
|
||||
@@ -236,12 +237,24 @@ pub async fn serve_content(
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
// Verify payment token
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if !verify_payment_token(data_dir, token, *price_sats).await {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
authorized = true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
@@ -317,10 +330,63 @@ pub enum PreviewResult {
|
||||
BlurPreview(Vec<u8>, String),
|
||||
/// Truncated preview for paid video (first ~2% of bytes).
|
||||
TruncatedPreview(Vec<u8>, String, u64),
|
||||
/// A preview can't be produced for this media without re-encoding (e.g. a
|
||||
/// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
|
||||
/// play). The UI shows its "preview unavailable" overlay instead of a
|
||||
/// broken player. (#35)
|
||||
PreviewUnavailable,
|
||||
/// Content not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Scan an MP4's top-level boxes and report whether `moov` appears before
|
||||
/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is
|
||||
/// playable), `Some(false)` if the media data precedes the index (a prefix
|
||||
/// will NOT play), or `None` if neither box is found / the file isn't parseable
|
||||
/// as ISO-BMFF (caller falls back to the legacy prefix behavior).
|
||||
async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
let mut f = tokio::fs::File::open(path).await.ok()?;
|
||||
let file_len = f.metadata().await.ok()?.len();
|
||||
let mut pos: u64 = 0;
|
||||
// Bound the walk so a malformed file can't spin forever.
|
||||
for _ in 0..1024 {
|
||||
if pos.saturating_add(8) > file_len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(pos)).await.ok()?;
|
||||
let mut hdr = [0u8; 8];
|
||||
if f.read_exact(&mut hdr).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64;
|
||||
let btype = &hdr[4..8];
|
||||
let mut header_len = 8u64;
|
||||
if size == 1 {
|
||||
// 64-bit extended size.
|
||||
let mut ext = [0u8; 8];
|
||||
if f.read_exact(&mut ext).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
size = u64::from_be_bytes(ext);
|
||||
header_len = 16;
|
||||
} else if size == 0 {
|
||||
// Box runs to EOF — it's the last one.
|
||||
size = file_len.saturating_sub(pos);
|
||||
}
|
||||
match btype {
|
||||
b"moov" => return Some(true), // index before media → faststart
|
||||
b"mdat" => return Some(false), // media before index → not faststart
|
||||
_ => {}
|
||||
}
|
||||
if size < header_len {
|
||||
return None; // malformed
|
||||
}
|
||||
pos = pos.checked_add(size)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Serve a preview of content by ID. For paid content, returns degraded previews:
|
||||
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
|
||||
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
|
||||
@@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
);
|
||||
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
|
||||
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
|
||||
// A byte-prefix preview only plays if the container's index is at
|
||||
// the front. For MP4/MOV that means the `moov` atom must precede
|
||||
// `mdat` (faststart). Non-faststart files have moov at the end, so
|
||||
// a 10% prefix is an unplayable truncated MP4 (#35) — report it as
|
||||
// unavailable rather than streaming bytes that hang the player.
|
||||
let is_isobmff = mime == "video/mp4"
|
||||
|| mime == "video/quicktime"
|
||||
|| matches!(
|
||||
file_path.extension().and_then(|e| e.to_str()),
|
||||
Some("mp4") | Some("m4v") | Some("mov") | Some("m4a")
|
||||
);
|
||||
if is_isobmff && mp4_is_faststart(&file_path).await == Some(false) {
|
||||
debug!(
|
||||
"Paid {} '{}' is a non-faststart MP4 (moov after mdat) — no playable prefix preview",
|
||||
if mime.starts_with("video/") { "video" } else { "audio" },
|
||||
id
|
||||
);
|
||||
return Ok(PreviewResult::PreviewUnavailable);
|
||||
}
|
||||
|
||||
// Serve first 10% of video/audio, minimum 512KB for codec headers
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
@@ -431,3 +517,41 @@ async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod faststart_tests {
|
||||
use super::*;
|
||||
|
||||
fn box_hdr(size: u32, typ: &[u8; 4]) -> Vec<u8> {
|
||||
let mut v = size.to_be_bytes().to_vec();
|
||||
v.extend_from_slice(typ);
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_faststart_moov_before_mdat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("fast.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
data.extend(box_hdr(8, b"mdat"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_non_faststart_mdat_before_moov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("slow.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(16, b"mdat"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +12,13 @@ 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, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
remove_node, save_nodes, set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -117,9 +117,12 @@ fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
|
||||
/// or `None` if the request was deduplicated or rate-limited.
|
||||
///
|
||||
/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a
|
||||
/// `Pending` entry, do not insert a second one — the user will see the
|
||||
/// existing row and act on that. Otherwise count `Pending` entries per
|
||||
/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`.
|
||||
/// `Pending` OR `Approved` entry, do not insert a second one. Including
|
||||
/// `Approved` is what stops an already-approved peer from re-spawning a fresh
|
||||
/// pending row every time their request re-syncs (the reported "approve, Poll
|
||||
/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT
|
||||
/// matched so a previously-rejected peer can still ask again later. Otherwise
|
||||
/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`.
|
||||
pub async fn insert_inbound(
|
||||
data_dir: &Path,
|
||||
from_nostr_pubkey: String,
|
||||
@@ -131,13 +134,13 @@ pub async fn insert_inbound(
|
||||
let mut requests = load_pending(data_dir).await?;
|
||||
expire_stale(&mut requests);
|
||||
|
||||
let already_pending = requests.iter().any(|r| {
|
||||
let already_handled = requests.iter().any(|r| {
|
||||
r.from_nostr_pubkey == from_nostr_pubkey
|
||||
&& r.from_did == from_did
|
||||
&& matches!(r.state, PendingState::Pending)
|
||||
&& matches!(r.state, PendingState::Pending | PendingState::Approved)
|
||||
&& !r.outbound
|
||||
});
|
||||
if already_pending {
|
||||
if already_handled {
|
||||
save_pending(data_dir, &requests).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -271,6 +274,54 @@ mod tests {
|
||||
assert!(r2.is_none(), "duplicate Pending request should be ignored");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approved_request_does_not_respawn_pending() {
|
||||
// Regression for the "approve → Poll Now → approved + a fresh pending"
|
||||
// loop: once a request is Approved, a re-synced inbound for the same
|
||||
// peer must NOT create a new Pending row.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r1 = insert_inbound(
|
||||
dir.path(),
|
||||
"npk1".into(),
|
||||
"npub1".into(),
|
||||
"did:key:zABC".into(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("first insert stored");
|
||||
|
||||
set_state(dir.path(), &r1.id, PendingState::Approved)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let r2 = insert_inbound(
|
||||
dir.path(),
|
||||
"npk1".into(),
|
||||
"npub1".into(),
|
||||
"did:key:zABC".into(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
r2.is_none(),
|
||||
"an already-approved peer must not re-spawn a pending request"
|
||||
);
|
||||
|
||||
let pending = load_pending(dir.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.filter(|r| matches!(r.state, PendingState::Pending))
|
||||
.count(),
|
||||
0,
|
||||
"no Pending rows should remain after approval + re-sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
|
||||
/// federation discovery can't silently re-add a peer they deleted.
|
||||
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedFile {
|
||||
pub(crate) removed: Vec<RemovedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedNode {
|
||||
pub(crate) did: String,
|
||||
pub(crate) removed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
@@ -44,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
Ok(dedup_nodes_by_onion(file.nodes))
|
||||
}
|
||||
|
||||
/// Collapse entries that share an onion. An onion is a node's stable, unique
|
||||
/// network identity, so two entries with the same onion are the SAME physical
|
||||
/// node lingering under two dids (e.g. after a did/key change). Returning both
|
||||
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
|
||||
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
|
||||
/// from the duplicates into it, then drop them. Non-destructive to disk; the
|
||||
/// deduped list persists the next time nodes are saved (add/sync).
|
||||
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_onion: HashMap<String, usize> = HashMap::new();
|
||||
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
let key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if key.is_empty() {
|
||||
out.push(node);
|
||||
continue;
|
||||
}
|
||||
if let Some(&idx) = by_onion.get(&key) {
|
||||
let kept = &mut out[idx];
|
||||
if kept.fips_npub.is_none() {
|
||||
kept.fips_npub = node.fips_npub;
|
||||
}
|
||||
if kept.name.is_none() {
|
||||
kept.name = node.name;
|
||||
}
|
||||
if kept.last_state.is_none() {
|
||||
kept.last_state = node.last_state;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
by_onion.insert(key, out.len());
|
||||
out.push(node);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up a federated peer's FIPS npub given their onion address.
|
||||
@@ -114,6 +164,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
// Explicitly (re-)adding a node clears any prior tombstone so the
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@@ -127,9 +180,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Load the set of tombstoned (operator-removed) DIDs.
|
||||
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(std::collections::HashSet::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read removed nodes")?;
|
||||
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.removed.into_iter().map(|r| r.did).collect())
|
||||
}
|
||||
|
||||
/// Record a DID as removed. Idempotent.
|
||||
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let path = dir.join(REMOVED_FILE);
|
||||
let mut file: RemovedFile = if path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
RemovedFile::default()
|
||||
};
|
||||
if !file.removed.iter().any(|r| r.did == did) {
|
||||
file.removed.push(RemovedNode {
|
||||
did: did.to_string(),
|
||||
removed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear a DID's tombstone (operator explicitly re-added it).
|
||||
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file: RemovedFile =
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let before = file.removed.len();
|
||||
file.removed.retain(|r| r.did != did);
|
||||
if file.removed.len() != before {
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
@@ -236,6 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_nodes_by_onion_collapses_same_onion() {
|
||||
// Two entries share an onion (same physical node under two dids) — must
|
||||
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
|
||||
let mut dup = make_node("did:key:zDUP", "shared.onion");
|
||||
dup.fips_npub = Some("npub1merged".to_string());
|
||||
dup.name = Some("Sapien".to_string());
|
||||
let nodes = vec![
|
||||
make_node("did:key:zKEEP", "shared.onion"),
|
||||
dup,
|
||||
make_node("did:key:zOTHER", "other.onion"),
|
||||
];
|
||||
let out = dedup_nodes_by_onion(nodes);
|
||||
assert_eq!(out.len(), 2, "two distinct onions remain");
|
||||
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
|
||||
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
|
||||
assert_eq!(
|
||||
kept.fips_npub.as_deref(),
|
||||
Some("npub1merged"),
|
||||
"merges fips_npub from the dropped duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
kept.name.as_deref(),
|
||||
Some("Sapien"),
|
||||
"merges name from the dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_onion_suffix_insensitive() {
|
||||
// The ".onion" suffix must not affect the match.
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "abc"),
|
||||
make_node("did:key:z2", "abc.onion"),
|
||||
];
|
||||
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -287,6 +439,36 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_tombstones_and_readd_clears_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
// No tombstones yet.
|
||||
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
|
||||
|
||||
// Removing tombstones the DID so transitive discovery won't re-add it.
|
||||
remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
let removed = load_removed_dids(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
removed.contains("did:key:z1"),
|
||||
"removed DID must be tombstoned"
|
||||
);
|
||||
|
||||
// Explicitly re-adding clears the tombstone (intentional re-federate).
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!load_removed_dids(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("did:key:z1"),
|
||||
"explicit re-add must clear the tombstone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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")?;
|
||||
@@ -118,6 +124,12 @@ async fn merge_transitive_peers(
|
||||
return Ok(());
|
||||
}
|
||||
let mut nodes = super::storage::load_nodes(data_dir).await?;
|
||||
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
|
||||
// them via transitive discovery, or deleted (e.g. stale test) nodes
|
||||
// reappear on the next sync with any peer that still lists them.
|
||||
let removed = super::storage::load_removed_dids(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut added = 0u32;
|
||||
let mut refreshed = 0u32;
|
||||
|
||||
@@ -127,6 +139,10 @@ async fn merge_transitive_peers(
|
||||
if hint.did == source_did || hint.did == local_did {
|
||||
continue;
|
||||
}
|
||||
// Skip anything the operator deliberately removed.
|
||||
if removed.contains(&hint.did) {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||
// Already known — just refresh fips_npub if we didn't have one.
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
@@ -135,6 +151,27 @@ async fn merge_transitive_peers(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Same physical node advertised under a DIFFERENT did? Match on the
|
||||
// onion (its stable network identity). Without this, a node that
|
||||
// appears under two dids (e.g. after a key/did change) gets added
|
||||
// twice — showing up duplicated in the trusted-node list (B1) and as
|
||||
// two separate mesh chat contacts (B2). Merge into the existing entry.
|
||||
let hint_onion = hint.onion.trim_end_matches(".onion");
|
||||
if !hint_onion.is_empty() {
|
||||
if let Some(existing) = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
|
||||
{
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
existing.fips_npub = hint.fips_npub.clone();
|
||||
}
|
||||
if existing.name.is_none() && hint.name.is_some() {
|
||||
existing.name = hint.name.clone();
|
||||
}
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nodes.push(FederatedNode {
|
||||
did: hint.did.clone(),
|
||||
pubkey: hint.pubkey.clone(),
|
||||
|
||||
@@ -28,12 +28,38 @@ use tokio::process::Command;
|
||||
/// On-disk filename under `data_dir/`.
|
||||
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||
/// remove it from the UI once their own cluster has independent anchors.
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
|
||||
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
|
||||
/// Operators can remove it from the UI once their own cluster has
|
||||
/// independent anchors (removal persists, see `load`/`remove`).
|
||||
///
|
||||
/// IMPORTANT transport details, learned the hard way (see git history /
|
||||
/// the 2026-06-15 debugging on .116):
|
||||
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
|
||||
/// (host pings on both IP families but never completes a UDP FIPS
|
||||
/// handshake). `fips/config.rs` always knew this; the old default
|
||||
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
|
||||
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
|
||||
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
|
||||
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
|
||||
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
|
||||
/// An IPv4 literal sidesteps the resolver entirely.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
|
||||
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Public anchor (fips.v0l.io)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_ANCHORS_FILE)
|
||||
}
|
||||
|
||||
/// Load the seed-anchor list. Returns an empty list if the file
|
||||
/// doesn't exist yet — a first-boot node with no operator config.
|
||||
/// Load the seed-anchor list. A node that has never edited its anchor
|
||||
/// list (no file yet) gets the default public anchor so it can bootstrap
|
||||
/// the mesh out of the box. Once the operator edits anchors — including
|
||||
/// removing the default — a file exists and is authoritative, so removal
|
||||
/// persists and we never silently re-add it.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||
///
|
||||
/// Invoked through `sudo -n`: the upstream daemon's control socket
|
||||
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
|
||||
/// archipelago service user is not in the `fips` group, so a bare
|
||||
/// `fipsctl connect` fails with EACCES. This matches the privileged
|
||||
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
|
||||
/// Without it, seed anchors persist to disk but never actually dial,
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
"sudo fipsctl connect exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
@@ -185,10 +230,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
|
||||
/// path filter can restrict the exposed surface.
|
||||
pub const PEER_PORT: u16 = 5679;
|
||||
|
||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
|
||||
/// worth retrying over Tor, which reaches a different (less-filtered) route.
|
||||
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
|
||||
/// and are returned as-is so we neither mask real errors nor double latency.
|
||||
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -82,17 +93,71 @@ pub async fn peer_base_url(npub: &str) -> Result<String> {
|
||||
Ok(format!("http://[{}]:{}", ip, PEER_PORT))
|
||||
}
|
||||
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy,
|
||||
/// short timeout — fall back to Tor on failure.
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy.
|
||||
/// `connect_timeout` is generous enough to let NAT hole-punching complete on
|
||||
/// the first dial (FIPS is UDP hole-punched; the path often isn't established
|
||||
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
|
||||
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
|
||||
pub fn client() -> reqwest::Client {
|
||||
client_with_timeout(Duration::from_secs(20))
|
||||
}
|
||||
|
||||
/// FIPS client with a caller-chosen overall request timeout. The static 20s
|
||||
/// `client()` budget is fine for catalog browses and short calls, but a large
|
||||
/// content download (#38) needs the per-request timeout the caller asked for —
|
||||
/// otherwise a 178MB transfer is aborted at 20s and the whole download fails
|
||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
.build()
|
||||
.expect("static reqwest client config")
|
||||
}
|
||||
|
||||
/// Send a FIPS request with ONE retry on a connect/timeout error.
|
||||
///
|
||||
/// The first dial to a peer typically triggers NAT hole-punching and can time
|
||||
/// out before the overlay path is established; a quick retry then lands on the
|
||||
/// now-warm path. Without this, a single cold-path failure drops the call to
|
||||
/// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't
|
||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let retry = rb.try_clone();
|
||||
match rb.send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
||||
// Brief pause so the hole-punch packets from the first attempt can
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
retry.expect("retry builder present").send().await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay
|
||||
/// address and open a short connection to its peer listener. Hole-punched
|
||||
/// paths and NAT mappings go cold after ~30-60s of no traffic, after which the
|
||||
/// next real dial pays the full re-punch cost and often falls back to Tor.
|
||||
/// Keeping the path warm is what makes FIPS the transport that actually gets
|
||||
/// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the
|
||||
/// connection attempt itself is what re-punches and refreshes the path.
|
||||
pub async fn warm_path(npub: &str) {
|
||||
if !is_service_active().await {
|
||||
return;
|
||||
}
|
||||
let Ok(base) = peer_base_url(npub).await else {
|
||||
return;
|
||||
};
|
||||
let c = client();
|
||||
// The response status is irrelevant; establishing the connection warms it.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await;
|
||||
}
|
||||
|
||||
// ── DNS wire-format helpers ─────────────────────────────────────────────
|
||||
|
||||
fn encode_query(id: u16, npub: &str) -> Result<Vec<u8>> {
|
||||
@@ -243,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>,
|
||||
}
|
||||
|
||||
@@ -254,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.
|
||||
@@ -294,13 +383,22 @@ impl<'a> PeerRequest<'a> {
|
||||
let pref = self.preference().await;
|
||||
// FIPS-only or Auto: try FIPS first.
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_post_json(body).await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_post_json(body).await? {
|
||||
Some(resp) => {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
@@ -312,13 +410,19 @@ impl<'a> PeerRequest<'a> {
|
||||
use crate::settings::transport::TransportPref;
|
||||
let pref = self.preference().await;
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_get().await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
@@ -343,15 +447,19 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
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);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS POST {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -372,15 +480,19 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
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);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,63 @@ pub mod service;
|
||||
pub mod update;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
|
||||
/// materialised the fips key, install the daemon config + start the service if
|
||||
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
|
||||
/// transport and should come up on its own — the UI "Activate" button is now a
|
||||
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
|
||||
/// when the service is already active.
|
||||
pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
let identity_dir = identity_dir_from(data_dir);
|
||||
if !identity_dir.join("fips_key").exists() {
|
||||
return; // pre-onboarding: nothing to activate yet
|
||||
}
|
||||
if dial::is_service_active().await {
|
||||
return; // already up
|
||||
}
|
||||
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
|
||||
if let Err(e) = config::install(&identity_dir).await {
|
||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("FIPS auto-activated");
|
||||
}
|
||||
|
||||
/// Spawn the FIPS supervisor: every 45s 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.
|
||||
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));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||
ensure_activated(&data_dir).await;
|
||||
if !dial::is_service_active().await {
|
||||
continue;
|
||||
}
|
||||
let nodes = crate::federation::load_nodes(&data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut handles = Vec::new();
|
||||
for node in nodes {
|
||||
if let Some(npub) = node.fips_npub.clone() {
|
||||
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Systemd unit name supervised by archipelago.
|
||||
|
||||
@@ -1,156 +1,401 @@
|
||||
//! User-triggered FIPS upgrade from the upstream default branch.
|
||||
//! User-triggered FIPS upgrade from upstream GitHub releases.
|
||||
//!
|
||||
//! Flow (no auto-update, no background polling — user clicks a button):
|
||||
//! 1. Query GitHub for the upstream repo's default branch, then the
|
||||
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
||||
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
||||
//! 2. Compare with the installed daemon version reported by
|
||||
//! `fipsctl --version`. If identical, report "up to date".
|
||||
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
||||
//! 4. SHA256-verify the download.
|
||||
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
||||
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
|
||||
//! (`/releases/latest` returns the newest non-prerelease, non-draft
|
||||
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
|
||||
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
|
||||
//! reported by `fipsctl --version`. A dev/pre-release build of the
|
||||
//! same number (`0.3.0-dev`) counts as older than the released tag.
|
||||
//! 3. Pick the Debian package asset matching the host architecture
|
||||
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
|
||||
//! 4. Download both, SHA256-verify the .deb against the checksums file.
|
||||
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
|
||||
//!
|
||||
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
||||
//! publish stable release assets for per-commit builds. This module
|
||||
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
||||
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
||||
//! returns a clear "not yet available" error until the artefact source
|
||||
//! is decided.
|
||||
//! Upstream began publishing tagged releases with `.deb` artefacts and
|
||||
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
|
||||
//! the apply path is fully wired against those assets.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{service, UPSTREAM_REPO};
|
||||
|
||||
const GITHUB_API: &str = "https://api.github.com";
|
||||
const USER_AGENT: &str = "archipelago-fips-updater";
|
||||
|
||||
/// Result of `check_update()` — what the dashboard renders.
|
||||
/// Result of `check()` — what the dashboard renders.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheck {
|
||||
/// Currently installed daemon version (from `fipsctl --version`).
|
||||
pub current: Option<String>,
|
||||
/// Short SHA of the latest commit on upstream `main`.
|
||||
pub latest_commit: String,
|
||||
/// True when the installed version string does not mention the latest SHA.
|
||||
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
|
||||
pub latest_version: String,
|
||||
/// True when the installed version is older than `latest_version`.
|
||||
pub update_available: bool,
|
||||
/// Release channel this check tracked. Currently always "stable".
|
||||
pub channel: String,
|
||||
/// Browser download URL of the architecture-matched .deb for the
|
||||
/// latest release, when one exists (informational; apply() re-resolves).
|
||||
pub asset_url: Option<String>,
|
||||
/// Human-readable note for the UI.
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest commit on the upstream default branch and
|
||||
/// compare to the installed version. Never errors on "no package installed"
|
||||
/// — that is itself a valid state where an update is available.
|
||||
/// One GitHub release as we consume it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Build HTTP client")
|
||||
}
|
||||
|
||||
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
|
||||
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
|
||||
/// asset lookup simply finds nothing and surfaces a clear error.
|
||||
fn deb_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest stable release and compare to the installed
|
||||
/// version. Never errors on "no package installed" — that is itself a valid
|
||||
/// state where an update is available.
|
||||
pub async fn check() -> Result<UpdateCheck> {
|
||||
let current = service::daemon_version().await.ok();
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
let branch = fetch_default_branch(&client).await?;
|
||||
let latest = fetch_head_sha(&client, &branch).await?;
|
||||
let short = latest.chars().take(7).collect::<String>();
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
let update_available = match ¤t {
|
||||
Some(v) => !v.contains(&short),
|
||||
Some(v) => version_is_older(v, &release.tag_name),
|
||||
None => true,
|
||||
};
|
||||
|
||||
let asset_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.map(|a| a.browser_download_url.clone());
|
||||
|
||||
let notes = if update_available {
|
||||
format!(
|
||||
"Upstream {} is at {}; installed: {}",
|
||||
branch,
|
||||
short,
|
||||
"Update available: {} (installed: {})",
|
||||
release.tag_name,
|
||||
current.as_deref().unwrap_or("not installed")
|
||||
)
|
||||
} else {
|
||||
format!("Up to date ({} @ {})", branch, short)
|
||||
format!("Up to date ({})", release.tag_name)
|
||||
};
|
||||
|
||||
Ok(UpdateCheck {
|
||||
current,
|
||||
latest_commit: short,
|
||||
latest_version: release.tag_name,
|
||||
update_available,
|
||||
channel: "stable".to_string(),
|
||||
asset_url,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the update. Stubbed pending a stable artefact source for
|
||||
/// per-commit builds of the `fips` debian package. When this is wired
|
||||
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
/// Download, verify, and install the latest stable FIPS release, then
|
||||
/// restart the daemon. Steps: resolve release → match .deb for this arch
|
||||
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
pub async fn apply() -> Result<()> {
|
||||
anyhow::bail!(
|
||||
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
||||
per-commit .deb artefacts for main. Upgrade manually for now: \
|
||||
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
||||
)
|
||||
}
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
let deb = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no .deb for architecture {}",
|
||||
release.tag_name,
|
||||
deb_arch()
|
||||
)
|
||||
})?;
|
||||
let checksums = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "checksums-linux.txt")
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
|
||||
})?;
|
||||
|
||||
// Download the .deb (bytes) and the checksums (text).
|
||||
let deb_bytes = client
|
||||
.get(&deb.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub repo API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
||||
body.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
||||
}
|
||||
|
||||
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.context("download .deb")?
|
||||
.error_for_status()
|
||||
.context(".deb download HTTP error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("read .deb body")?;
|
||||
let checksums_text = client
|
||||
.get(&checksums.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub commits API")?;
|
||||
if !resp.status().is_success() {
|
||||
.context("download checksums")?
|
||||
.error_for_status()
|
||||
.context("checksums download HTTP error")?
|
||||
.text()
|
||||
.await
|
||||
.context("read checksums body")?;
|
||||
|
||||
// Verify SHA256 against the checksums manifest (sha256sum format:
|
||||
// "<hex>␠␠<filename>"). The filename column may include a leading
|
||||
// "*" (binary mode) or a path prefix, so match on the basename.
|
||||
let expected = checksums_text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let hash = parts.next()?;
|
||||
let name = parts.next()?.trim_start_matches('*');
|
||||
let base = name.rsplit('/').next().unwrap_or(name);
|
||||
(base == deb.name).then(|| hash.to_lowercase())
|
||||
})
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
|
||||
|
||||
let actual = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&deb_bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
if actual != expected {
|
||||
anyhow::bail!(
|
||||
"GitHub commits API returned {} for branch {}",
|
||||
resp.status(),
|
||||
branch
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
deb.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
||||
body.get("sha")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
||||
|
||||
// Stage the verified .deb in /tmp (shared with the host — the
|
||||
// service runs with PrivateTmp=no) and install it.
|
||||
let dest = std::env::temp_dir().join(&deb.name);
|
||||
tokio::fs::write(&dest, &deb_bytes)
|
||||
.await
|
||||
.with_context(|| format!("write {}", dest.display()))?;
|
||||
|
||||
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
|
||||
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
|
||||
// and `/var/lib/dpkg` are read-only *inside the service's mount
|
||||
// namespace* — and a `sudo` child inherits that namespace, so a
|
||||
// bare `sudo dpkg -i` fails with "Read-only file system" on the
|
||||
// dpkg database. `systemd-run` asks PID 1 to launch the command in
|
||||
// a fresh transient scope outside our sandbox, where the real
|
||||
// (writable) host filesystem is visible. `--wait` blocks until it
|
||||
// finishes and propagates the exit status; `--pipe` forwards
|
||||
// dpkg's output; `--collect` reaps the unit even on failure.
|
||||
//
|
||||
// dpkg flags, both load-bearing for this package specifically:
|
||||
// --force-confold: the fips package ships conffiles under
|
||||
// /etc/fips that archipelago rewrites at install time, so dpkg
|
||||
// hits an interactive "keep/replace?" conffile prompt. With our
|
||||
// closed stdin that aborts the configure step ("EOF on stdin at
|
||||
// conffile prompt") and leaves the package half-unpacked
|
||||
// (status `iU`), which `fips.status` then reports as
|
||||
// `installed:false`. confold = keep our managed config, no prompt.
|
||||
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
|
||||
// orders as NEWER than the stable tag `0.3.0` (a trailing
|
||||
// `-dev` sorts above the bare release). Moving a dev build onto
|
||||
// the stable line is therefore a dpkg "downgrade"; without this
|
||||
// flag dpkg warns and exits non-zero. Our own version_is_older()
|
||||
// gate already decided this is the wanted direction.
|
||||
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
|
||||
// other maintainer-script prompt.
|
||||
let dpkg = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"systemd-run",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--pipe",
|
||||
"--",
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg",
|
||||
"--force-confold",
|
||||
"--force-downgrade",
|
||||
"-i",
|
||||
])
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.await
|
||||
.context("sudo systemd-run dpkg -i failed to launch")?;
|
||||
// Best-effort cleanup regardless of dpkg result.
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
if !dpkg.status.success() {
|
||||
anyhow::bail!(
|
||||
"dpkg -i {} exited {}: {}",
|
||||
deb.name,
|
||||
dpkg.status,
|
||||
String::from_utf8_lossy(&dpkg.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Restart whichever fips unit is supervising the daemon so the new
|
||||
// binary takes over.
|
||||
let unit = service::active_unit().await;
|
||||
service::restart(unit)
|
||||
.await
|
||||
.with_context(|| format!("restart {} after install", unit))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/releases/latest` returns the most recent non-prerelease, non-draft
|
||||
/// release. We still re-check the flags defensively in case the endpoint
|
||||
/// or repo settings change.
|
||||
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
|
||||
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub releases/latest API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
|
||||
}
|
||||
let release: Release = resp.json().await.context("Parse release JSON")?;
|
||||
if release.draft || release.prerelease {
|
||||
anyhow::bail!(
|
||||
"releases/latest returned a {} release ({})",
|
||||
if release.draft { "draft" } else { "prerelease" },
|
||||
release.tag_name
|
||||
);
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
fn is_deb_for_arch(name: &str) -> bool {
|
||||
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
|
||||
}
|
||||
|
||||
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
|
||||
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
|
||||
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
|
||||
// Take the first whitespace token, drop a leading 'v'.
|
||||
let tok = s.split_whitespace().next().unwrap_or(s);
|
||||
let tok = tok.strip_prefix('v').unwrap_or(tok);
|
||||
// Split off any pre-release / build suffix.
|
||||
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
|
||||
Some(i) => (&tok[..i], &tok[i..]),
|
||||
None => (tok, ""),
|
||||
};
|
||||
let mut it = core.split('.');
|
||||
let major = it.next()?.parse::<u64>().ok()?;
|
||||
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let has_prerelease = rest.starts_with('-');
|
||||
Some(((major, minor, patch), has_prerelease))
|
||||
}
|
||||
|
||||
/// True when `installed` is strictly older than release tag `latest`.
|
||||
/// Same numeric triple but `installed` carries a pre-release suffix while
|
||||
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
|
||||
/// If either side can't be parsed, fall back to "differs ⇒ update".
|
||||
fn version_is_older(installed: &str, latest: &str) -> bool {
|
||||
match (parse_version(installed), parse_version(latest)) {
|
||||
(Some((ic, ipre)), Some((lc, lpre))) => {
|
||||
if ic != lc {
|
||||
ic < lc
|
||||
} else {
|
||||
// Equal cores: a pre-release is older than the final release.
|
||||
ipre && !lpre
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unparseable: be conservative — offer the update unless the
|
||||
// installed string already mentions the latest tag.
|
||||
!installed.contains(latest.trim_start_matches('v'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_returns_clear_stub_error() {
|
||||
let err = apply().await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("not yet wired"),
|
||||
"apply() should return an explicit not-yet-wired error, got: {}",
|
||||
err
|
||||
);
|
||||
#[test]
|
||||
fn test_deb_arch_maps_known() {
|
||||
// On the host running tests this is whatever the test arch is;
|
||||
// just assert it returns a non-empty, lowercase token.
|
||||
let a = deb_arch();
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, a.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_older() {
|
||||
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
|
||||
assert!(version_is_older("0.2.1", "v0.3.0"));
|
||||
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.4.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.1", "v0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
|
||||
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
|
||||
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
|
||||
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_deb_for_arch() {
|
||||
let arch = deb_arch();
|
||||
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
|
||||
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
|
||||
assert!(!is_deb_for_arch("checksums-linux.txt"));
|
||||
assert!(!is_deb_for_arch(&format!(
|
||||
"fips-0.3.0-linux-{}.tar.gz",
|
||||
arch
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_check_serialises() {
|
||||
let uc = UpdateCheck {
|
||||
current: Some("0.2.0-abc1234".to_string()),
|
||||
latest_commit: "def5678".to_string(),
|
||||
current: Some("0.3.0-dev".to_string()),
|
||||
latest_version: "v0.3.0".to_string(),
|
||||
update_available: true,
|
||||
channel: "stable".to_string(),
|
||||
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
|
||||
notes: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&uc).unwrap();
|
||||
assert!(json.contains("latest_commit"));
|
||||
assert!(json.contains("latest_version"));
|
||||
assert!(json.contains("update_available"));
|
||||
assert!(json.contains("stable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,12 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
"fedimint" => &["bitcoin"],
|
||||
"fedimint-gateway" => &["bitcoin", "fedimint"],
|
||||
|
||||
// IndeedHub stack
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
|
||||
// IndeedHub stack. The API needs MinIO (object storage) up before it
|
||||
// can serve — without listing it the health monitor would restart the
|
||||
// API while MinIO was still coming up, which is the "needs 1-2 restarts
|
||||
// to recover" symptom (#41). MinIO has no deps of its own, so the
|
||||
// monitor restarts it independently first; no deadlock.
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis", "indeedhub-minio"],
|
||||
"indeedhub" => &["indeedhub-api"],
|
||||
"indeedhub-relay" => &["indeedhub-postgres"],
|
||||
"indeedhub-ffmpeg" => &["indeedhub-api"],
|
||||
|
||||
@@ -33,9 +33,12 @@ mod bitcoin_rpc;
|
||||
mod bitcoin_status;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod container;
|
||||
mod content_hash;
|
||||
mod content_invoice;
|
||||
mod content_server;
|
||||
mod crash_recovery;
|
||||
mod credentials;
|
||||
@@ -64,9 +67,12 @@ mod server;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod storage_crypto;
|
||||
mod streaming;
|
||||
mod swarm;
|
||||
mod totp;
|
||||
mod transport;
|
||||
mod trust;
|
||||
mod update;
|
||||
mod vpn;
|
||||
mod wallet;
|
||||
@@ -81,6 +87,13 @@ use server::Server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Release-root signing ceremony: a publisher-side subcommand of the same
|
||||
// binary. Handle it before any server/tracing init so its stdout stays
|
||||
// clean (machine-readable KEY=VALUE lines) and it never touches node state.
|
||||
if ceremony::is_ceremony_invocation() {
|
||||
return ceremony::run();
|
||||
}
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
@@ -271,6 +284,15 @@ async fn main() -> Result<()> {
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||
// only — effective next reboot; never restarts the running service.
|
||||
tokio::spawn(bootstrap::ensure_archipelago_mount_ordering());
|
||||
|
||||
// #36: keep the kiosk unit + launcher hardened (CPU/mem cap + GPU-vs-headless
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
@@ -291,6 +313,31 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodically restart crashed multi-container stack members (immich,
|
||||
// indeedhub, …) at RUNTIME, not just at boot. The health monitor skips them
|
||||
// as "orphans" because the sub-container app_ids (e.g. immich_server) aren't
|
||||
// in package_data, so without this a crashed immich_server / indeedhub-api
|
||||
// never comes back until the next reboot (#16/#17). Reuses the boot
|
||||
// recovery, which cheaply skips already-running containers and respects the
|
||||
// user-stopped list, so this only acts on genuinely-down stack members.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(120));
|
||||
tick.tick().await; // consume the immediate tick; boot recovery covers t0
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
|
||||
if report.recovered > 0 {
|
||||
info!(
|
||||
"🔄 Stack supervisor: restarted {} crashed stack member(s) (failed: {:?})",
|
||||
report.recovered, report.failed
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
|
||||
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
|
||||
|
||||
@@ -306,6 +353,11 @@ async fn main() -> Result<()> {
|
||||
electrs_status::spawn_status_cache();
|
||||
bitcoin_status::spawn_status_cache();
|
||||
|
||||
// FIPS supervisor: auto-activate FIPS after onboarding (no Activate button
|
||||
// needed) and keep hole-punched paths to federation peers warm so peer dials
|
||||
// land on FIPS (the preferred transport) instead of falling back to Tor.
|
||||
fips::spawn_fips_supervisor(config.data_dir.clone());
|
||||
|
||||
let startup_ms = startup_start.elapsed().as_millis();
|
||||
info!(
|
||||
"Server listening on http://{} (startup: {}ms)",
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Mesh-AI assistant (issue #50) — answers `AssistQuery` messages with this
|
||||
//! node's local LLM and sends the reply back over the mesh.
|
||||
//!
|
||||
//! This is the Rust-native lift of Meshroller's "LLM bridge": a trusted peer
|
||||
//! asks a question over meshcore, an internet/compute-bearing node runs it
|
||||
//! through a local model (Ollama) and streams the answer back in capped,
|
||||
//! ordered chunks. Airtime is scarce, so the reply is length-capped and each
|
||||
//! 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;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Local Ollama generate endpoint (same host the Ollama app binds).
|
||||
const OLLAMA_URL: &str = "http://localhost:11434/api/generate";
|
||||
/// Default model when the node hasn't configured one (matches Meshroller).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
/// Anthropic Messages API (called with the shared proxy token).
|
||||
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// Default Claude model — Haiku 4.5: fast + cheap, ideal for short mesh answers.
|
||||
const CLAUDE_DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
|
||||
/// Max time to wait on the model before giving up.
|
||||
const OLLAMA_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Hard cap on answer length sent over the radio — keeps airtime sane.
|
||||
const MAX_REPLY_CHARS: usize = 480;
|
||||
/// Characters of answer text per `AssistResponse` chunk.
|
||||
const CHUNK_CHARS: usize = 160;
|
||||
/// Tighter cap for plain-text channel replies (bare `!ai` clients) — these
|
||||
/// aren't reassembled by an archipelago UI, so keep them to a couple frames.
|
||||
const CHANNEL_REPLY_CHARS: usize = 200;
|
||||
|
||||
/// Where an answer should go.
|
||||
pub(super) enum AssistReply {
|
||||
/// Typed `AssistResponse` chunks addressed to one peer — the archipelago
|
||||
/// UI path (rich, reassembled, correlated by `req_id`).
|
||||
Typed { contact_id: u32 },
|
||||
/// 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, 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
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some("denied".to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// One in-flight query per asker.
|
||||
{
|
||||
let mut inflight = state.assist_inflight.write().await;
|
||||
if !inflight.insert(asker) {
|
||||
warn!(
|
||||
from = asker,
|
||||
"AssistQuery dropped — asker already has one in flight"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistQueryReceived {
|
||||
from_contact_id: asker,
|
||||
prompt: prompt.clone(),
|
||||
});
|
||||
|
||||
let (backend, configured_model) = {
|
||||
let a = state.assistant.read().await;
|
||||
(a.backend.clone(), a.model.clone())
|
||||
};
|
||||
let is_claude = backend == "claude";
|
||||
let default_model = if is_claude {
|
||||
CLAUDE_DEFAULT_MODEL
|
||||
} else {
|
||||
DEFAULT_MODEL
|
||||
};
|
||||
let model = model_override
|
||||
.or(configured_model)
|
||||
.unwrap_or_else(|| default_model.to_string());
|
||||
|
||||
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
|
||||
|
||||
let result = if is_claude {
|
||||
call_claude(&state.data_dir, &model, &prompt).await
|
||||
} else {
|
||||
call_ollama(&model, &prompt).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
send_reply(&state, &reply, req_id, &answer).await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(req_id, "AI query failed: {}", e);
|
||||
send_failure(&state, &reply, req_id, "AI unavailable").await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.assist_inflight.write().await.remove(&asker);
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
///
|
||||
/// 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) {
|
||||
// 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),
|
||||
}
|
||||
};
|
||||
|
||||
// Never answer a user-blocked contact, regardless of policy.
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
if state
|
||||
.contacts
|
||||
.read()
|
||||
.await
|
||||
.get(pk)
|
||||
.map(|c| c.blocked)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
|
||||
// trusted_only policy — but only when the message is authenticated, so a
|
||||
// spoofed packet claiming an allowlisted key can't slip through.
|
||||
if authenticated {
|
||||
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 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();
|
||||
nodes.iter().any(|n| {
|
||||
n.trust_level == TrustLevel::Trusted
|
||||
&& (Some(&n.pubkey) == pubkey_hex.as_ref() || Some(&n.did) == did.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= MAX_REPLY_CHARS {
|
||||
return (trimmed.to_string(), false);
|
||||
}
|
||||
let capped: String = trimmed.chars().take(MAX_REPLY_CHARS).collect();
|
||||
(format!("{capped}…(truncated)"), true)
|
||||
}
|
||||
|
||||
/// Send a successful answer via the requested reply path.
|
||||
async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, answer: &str) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_typed_chunks(state, *contact_id, req_id, &text).await;
|
||||
}
|
||||
AssistReply::ChannelText { channel } => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a failure notice via the requested reply path.
|
||||
async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, msg: &str) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: String::new(),
|
||||
seq: 0,
|
||||
done: true,
|
||||
error: Some(msg.to_string()),
|
||||
};
|
||||
send_typed_response(state, *contact_id, &payload).await;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the answer into ordered `AssistResponse` chunks and send each back to
|
||||
/// the asker on the encrypted, peer-addressed path (archipelago UI path).
|
||||
async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id: u64, text: &str) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let chunks: Vec<String> = if chars.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
chars
|
||||
.chunks(CHUNK_CHARS)
|
||||
.map(|c| c.iter().collect())
|
||||
.collect()
|
||||
};
|
||||
let last = chunks.len().saturating_sub(1);
|
||||
for (i, chunk) in chunks.into_iter().enumerate() {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: chunk,
|
||||
seq: i as u16,
|
||||
done: i == last,
|
||||
error: None,
|
||||
};
|
||||
send_typed_response(state, dest_contact_id, &payload).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an `AssistResponse` payload and send it to a peer.
|
||||
async fn send_typed_response(
|
||||
state: &Arc<MeshState>,
|
||||
dest_contact_id: u32,
|
||||
payload: &AssistResponsePayload,
|
||||
) {
|
||||
let bytes = match message_types::encode_payload(payload) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to encode AssistResponse: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let envelope = message_types::TypedEnvelope::new(MeshMessageType::AssistResponse, bytes);
|
||||
match envelope.to_wire() {
|
||||
Ok(wire) => send_to_peer(state, dest_contact_id, wire).await,
|
||||
Err(e) => warn!("Failed to encode AssistResponse envelope: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
.send_cmd(MeshCommand::BroadcastChannel {
|
||||
channel,
|
||||
payload: text.as_bytes().to_vec(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Cap a plain-text channel reply to a couple of frames.
|
||||
fn cap_channel(answer: &str) -> String {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= CHANNEL_REPLY_CHARS {
|
||||
return format!("AI: {trimmed}");
|
||||
}
|
||||
let capped: String = trimmed.chars().take(CHANNEL_REPLY_CHARS).collect();
|
||||
format!("AI: {capped}…")
|
||||
}
|
||||
|
||||
/// Call the local Ollama model and return the generated text.
|
||||
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
});
|
||||
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Ollama returned HTTP {}", resp.status());
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let text = json
|
||||
.get("response")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Ollama returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Call Claude via the Anthropic Messages API using the node's shared proxy
|
||||
/// token at `secrets/claude-api-key`. Keeps answers short for radio airtime.
|
||||
async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let key = tokio::fs::read_to_string(data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("Claude API key is empty");
|
||||
}
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"max_tokens": 512,
|
||||
"system": "You answer questions over a low-bandwidth radio mesh. Reply in at most two short sentences. No markdown, no preamble.",
|
||||
"messages": [{ "role": "user", "content": prompt }],
|
||||
});
|
||||
let resp = client
|
||||
.post(CLAUDE_URL)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Claude API HTTP {}: {}",
|
||||
status,
|
||||
txt.chars().take(180).collect::<String>()
|
||||
);
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
// `content` is an array of blocks; take the first text block.
|
||||
let text = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Claude returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
@@ -445,7 +445,9 @@ async fn encrypt_for_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: &
|
||||
/// Send raw wire bytes to a specific peer by contact_id.
|
||||
/// Encrypts directed messages via ratchet or shared secret when available.
|
||||
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
|
||||
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
/// `pub(super)` so sibling handlers (e.g. the AI assistant) can reply on the
|
||||
/// same encrypted, peer-addressed path the relay handlers use.
|
||||
pub(super) async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(peer) = peers.get(&contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
|
||||
@@ -352,6 +352,59 @@ pub(super) async fn store_plain_message(
|
||||
state.store_message(msg.clone()).await;
|
||||
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>` 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, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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) {
|
||||
return Some(t[p.len()..].trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a received identity broadcast from a peer.
|
||||
@@ -436,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,112 @@ 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) => {
|
||||
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
|
||||
Ok(query) => {
|
||||
if !state.assistant.read().await.enabled {
|
||||
debug!(
|
||||
from = sender_contact_id,
|
||||
"AssistQuery ignored — assistant disabled on this node"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
from = sender_contact_id,
|
||||
req_id = query.req_id,
|
||||
"AI query received over mesh"
|
||||
);
|
||||
let json = payload_to_json(&query);
|
||||
store_typed_message(
|
||||
state,
|
||||
sender_contact_id,
|
||||
sender_name,
|
||||
&query.prompt,
|
||||
"assist_query",
|
||||
json,
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
// Run the model + reply off the radio loop. Typed query →
|
||||
// typed chunked reply back to the asking peer.
|
||||
let assist_state = Arc::clone(state);
|
||||
let name = sender_name.to_string();
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
query.prompt,
|
||||
query.model,
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
assist_state,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
Err(e) => warn!("Failed to decode AssistQuery payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistResponse) => {
|
||||
match message_types::decode_payload::<message_types::AssistResponsePayload>(&envelope.v)
|
||||
{
|
||||
Ok(resp) => {
|
||||
let display = resp
|
||||
.error
|
||||
.clone()
|
||||
.map(|e| format!("AI error: {e}"))
|
||||
.unwrap_or_else(|| resp.text.clone());
|
||||
let json = payload_to_json(&resp);
|
||||
store_typed_message(
|
||||
state,
|
||||
sender_contact_id,
|
||||
sender_name,
|
||||
&display,
|
||||
"assist_response",
|
||||
json,
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => warn!("Failed to decode AssistResponse payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
@@ -697,6 +813,10 @@ async fn dispatch_block_header(
|
||||
sender_name: &str,
|
||||
state: &Arc<MeshState>,
|
||||
) {
|
||||
// Respect the receive toggle (issue #28): nodes can opt out of inbound headers.
|
||||
if !state.receive_block_headers {
|
||||
return;
|
||||
}
|
||||
// Compact binary format: height(8) + hash(32) + timestamp(4)
|
||||
match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
|
||||
Ok((height, hash_hex, timestamp)) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use super::super::message_types::TypedEnvelope;
|
||||
use super::super::protocol;
|
||||
use super::decode::{
|
||||
is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
|
||||
try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
};
|
||||
use super::dispatch::handle_typed_message;
|
||||
use super::MeshState;
|
||||
@@ -18,13 +18,37 @@ pub(super) async fn handle_frame(
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
let _ = our_x25519_secret; // reserved for future per-frame decryption
|
||||
match frame.code {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -109,7 +133,8 @@ pub(super) async fn handle_frame(
|
||||
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(state, channel_idx, &payload).await;
|
||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
||||
@@ -121,7 +146,8 @@ pub(super) async fn handle_frame(
|
||||
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(state, channel_idx, &payload).await;
|
||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse channel message: {}", e),
|
||||
@@ -146,7 +172,12 @@ pub(super) async fn handle_frame(
|
||||
/// local mesh peer pubkeys (or we can't tell), the inner payload is
|
||||
/// dispatched through the direct-message path so it lands in the right
|
||||
/// chat. Otherwise it's handled as a normal channel text/typed message.
|
||||
async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload: &[u8]) {
|
||||
async fn handle_channel_payload(
|
||||
state: &Arc<MeshState>,
|
||||
channel_idx: u8,
|
||||
payload: &[u8],
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) {
|
||||
// DM-via-channel wrapper (text form): the channel text carries an
|
||||
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
||||
// marker anywhere in the payload (the firmware auto-prepends the
|
||||
@@ -326,6 +357,34 @@ async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload
|
||||
return;
|
||||
}
|
||||
|
||||
// Archipelago identity broadcast (`ARCHY:`): upsert the sender's real
|
||||
// archipelago identity (DID + ed25519 + x25519) so trust-gating and
|
||||
// encrypted DMs work over BOTH meshcore and Meshtastic — the latter
|
||||
// otherwise only exposes synthetic node keys. Keyed by the archipelago
|
||||
// pubkey (federation_peer_contact_id) so it MERGES with the federation-
|
||||
// seeded peer instead of creating a duplicate chat thread. Not stored as
|
||||
// a chat message.
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
|
||||
// Ignore our own identity echoed back by the radio/channel.
|
||||
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
|
||||
return;
|
||||
}
|
||||
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
||||
handle_identity_received(
|
||||
contact_id,
|
||||
0,
|
||||
&did,
|
||||
&ed_hex,
|
||||
&x_hex,
|
||||
state,
|
||||
our_x25519_secret,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular channel broadcast (not DM-wrapped)
|
||||
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
||||
let chan_name = format!("Channel {}", channel_idx);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Reconnects on device disconnect
|
||||
//! - Manages peer cache and message store
|
||||
|
||||
mod assist;
|
||||
mod bitcoin;
|
||||
mod decode;
|
||||
pub(crate) mod dispatch;
|
||||
@@ -62,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,
|
||||
@@ -70,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.
|
||||
@@ -102,6 +121,8 @@ pub struct MeshState {
|
||||
pub session_manager: Arc<super::session::SessionManager>,
|
||||
/// Whether to encrypt directed relay messages (config toggle for rollback).
|
||||
pub encrypt_relay: bool,
|
||||
/// Whether to accept inbound Bitcoin block headers from peers (issue #28).
|
||||
pub receive_block_headers: bool,
|
||||
/// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at).
|
||||
pub presence: RwLock<HashMap<String, (String, u32, u64)>>,
|
||||
/// Contacts store — alias/notes/pinned/blocked per peer pubkey hex.
|
||||
@@ -121,6 +142,56 @@ pub struct MeshState {
|
||||
/// persistent contact table from regenerating rows the user just
|
||||
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
|
||||
pub radio_contact_blocklist: RwLock<HashSet<String>>,
|
||||
/// Mesh-AI assistant settings (issue #50): whether this node answers
|
||||
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
|
||||
/// so the UI toggle applies without restarting the listener.
|
||||
pub assistant: RwLock<AssistantConfig>,
|
||||
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
|
||||
/// federation trust list used to gate AI queries) without threading a path
|
||||
/// through every call.
|
||||
pub data_dir: std::path::PathBuf,
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssistantConfig {
|
||||
/// Answer AssistQuery messages with the local LLM.
|
||||
pub enabled: bool,
|
||||
/// Model to use; None → the backend's built-in default.
|
||||
pub model: Option<String>,
|
||||
/// Restrict asking to federation-Trusted peers (vs. anyone on the mesh).
|
||||
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
|
||||
@@ -151,8 +222,11 @@ impl MeshState {
|
||||
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
|
||||
stego_mode: super::steganography::SteganographyMode,
|
||||
encrypt_relay: bool,
|
||||
receive_block_headers: bool,
|
||||
session_manager: Arc<super::session::SessionManager>,
|
||||
our_ed_pubkey_hex: String,
|
||||
assistant: AssistantConfig,
|
||||
data_dir: std::path::PathBuf,
|
||||
) -> (
|
||||
Arc<Self>,
|
||||
broadcast::Receiver<MeshEvent>,
|
||||
@@ -187,11 +261,16 @@ impl MeshState {
|
||||
chunk_buffer: RwLock::new(HashMap::new()),
|
||||
session_manager,
|
||||
encrypt_relay,
|
||||
receive_block_headers,
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
contacts: RwLock::new(HashMap::new()),
|
||||
our_ed_pubkey_hex,
|
||||
blob_store: RwLock::new(None),
|
||||
radio_contact_blocklist: RwLock::new(HashSet::new()),
|
||||
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() {
|
||||
@@ -363,9 +476,9 @@ pub(super) async fn run_mesh_session(
|
||||
state: &Arc<MeshState>,
|
||||
preferred_path: Option<&str>,
|
||||
our_did: &str,
|
||||
_our_ed_pubkey_hex: &str,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
_our_x25519_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
server_name: Option<&str>,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
@@ -424,6 +537,17 @@ pub(super) async fn run_mesh_session(
|
||||
warn!("Failed to send initial advert: {}", 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;
|
||||
|
||||
@@ -491,6 +615,9 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// (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;
|
||||
}
|
||||
|
||||
@@ -541,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,
|
||||
@@ -594,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,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 +76,7 @@ impl MeshtasticDevice {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
peer_pubkeys: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -150,6 +159,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 +243,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 +328,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 +369,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)),
|
||||
@@ -418,6 +507,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 +518,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 +531,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 +549,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 +558,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 +567,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 +580,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 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
pki_encrypted,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ pub enum MeshMessageType {
|
||||
/// MCIIXXTT framing) and the peer has no Tor path. Recipient writes the
|
||||
/// bytes to its local BlobStore on reassembly.
|
||||
ContentInline = 23,
|
||||
/// "Ask the node's AI" — a prompt to be answered by the receiving node's
|
||||
/// local LLM (issue #50). Gated by the assistant config + trust policy.
|
||||
AssistQuery = 24,
|
||||
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
|
||||
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
|
||||
AssistResponse = 25,
|
||||
}
|
||||
|
||||
impl MeshMessageType {
|
||||
@@ -99,6 +105,8 @@ impl MeshMessageType {
|
||||
21 => Some(Self::ChannelInvite),
|
||||
22 => Some(Self::ContactCard),
|
||||
23 => Some(Self::ContentInline),
|
||||
24 => Some(Self::AssistQuery),
|
||||
25 => Some(Self::AssistResponse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -132,6 +140,8 @@ impl MeshMessageType {
|
||||
"channel_invite" => Some(Self::ChannelInvite),
|
||||
"contact_card" => Some(Self::ContactCard),
|
||||
"content_inline" => Some(Self::ContentInline),
|
||||
"assist_query" => Some(Self::AssistQuery),
|
||||
"assist_response" => Some(Self::AssistResponse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +172,8 @@ impl MeshMessageType {
|
||||
Self::ChannelInvite => "channel_invite",
|
||||
Self::ContactCard => "contact_card",
|
||||
Self::ContentInline => "content_inline",
|
||||
Self::AssistQuery => "assist_query",
|
||||
Self::AssistResponse => "assist_response",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +419,37 @@ pub struct LightningRelayPayload {
|
||||
pub request_id: u64,
|
||||
}
|
||||
|
||||
/// "Ask the node's AI" request (issue #50). Sent to a peer running a local
|
||||
/// LLM; answered with one or more `AssistResponsePayload` chunks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssistQueryPayload {
|
||||
/// Asker-chosen id correlating the query with its response chunks.
|
||||
pub req_id: u64,
|
||||
/// The natural-language prompt.
|
||||
pub prompt: String,
|
||||
/// Optional model override; falls back to the responder's configured model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// One chunk of an AI answer, addressed back to the asker by `req_id`.
|
||||
/// Airtime is scarce, so long answers are capped and split into ordered chunks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssistResponsePayload {
|
||||
pub req_id: u64,
|
||||
/// This chunk's text.
|
||||
pub text: String,
|
||||
/// 0-based chunk index.
|
||||
#[serde(default)]
|
||||
pub seq: u16,
|
||||
/// True on the final chunk.
|
||||
#[serde(default)]
|
||||
pub done: bool,
|
||||
/// Set instead of `text` when the query failed (model unreachable, denied…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightning relay response (proof of payment).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LightningRelayResponsePayload {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
pub mod ratchet;
|
||||
pub mod scheduler;
|
||||
pub mod serial;
|
||||
pub mod session;
|
||||
pub mod steganography;
|
||||
@@ -37,6 +38,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
|
||||
/// Derive a stable synthetic `contact_id` for a federation peer from its
|
||||
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
|
||||
@@ -44,6 +46,12 @@ const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-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 {
|
||||
@@ -53,6 +61,72 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -75,18 +149,61 @@ 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
|
||||
}
|
||||
|
||||
/// Purge a federation peer from all live mesh state and persisted contacts so
|
||||
/// removing a node (federation.remove-node) also clears its chat contact,
|
||||
/// thread, and any per-contact customisation — otherwise a stale/renamed node
|
||||
/// (e.g. an old "Arch HP" entry) lingers in the chat list even after it's gone
|
||||
/// from `nodes.json` (#2). Keyed by the synthetic `contact_id` for the peer
|
||||
/// table/messages and by `pubkey_hex` for the pubkey-keyed contacts/presence
|
||||
/// stores.
|
||||
pub(crate) async fn purge_federation_peer(
|
||||
state: &Arc<listener::MeshState>,
|
||||
contact_id: u32,
|
||||
pubkey_hex: &str,
|
||||
data_dir: &Path,
|
||||
) {
|
||||
state.peers.write().await.remove(&contact_id);
|
||||
state.shared_secrets.write().await.remove(&contact_id);
|
||||
state
|
||||
.messages
|
||||
.write()
|
||||
.await
|
||||
.retain(|m| m.peer_contact_id != contact_id);
|
||||
state.presence.write().await.remove(pubkey_hex);
|
||||
let mut contacts = state.contacts.write().await;
|
||||
if contacts.remove(pubkey_hex).is_some() {
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
if let Err(e) = save_mesh_contacts(data_dir, &snapshot).await {
|
||||
warn!("Failed to persist mesh contacts after purge: {}", e);
|
||||
}
|
||||
}
|
||||
state.update_peer_count().await;
|
||||
}
|
||||
|
||||
/// Load federation nodes from disk and upsert each as a synthetic mesh peer.
|
||||
/// Called at MeshService startup so the chat list already contains every
|
||||
/// known federation node — users can share files to them without first
|
||||
@@ -99,7 +216,17 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Skip nodes whose onion we've already seeded: the same physical node can
|
||||
// linger in the federation list under two dids (see B1/B2). Seeding both
|
||||
// would create two chat contacts for one node — one by name+logo and one
|
||||
// by raw did. One onion → one mesh contact.
|
||||
let mut seen_onions = std::collections::HashSet::new();
|
||||
for node in nodes {
|
||||
let onion_key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if !onion_key.is_empty() && !seen_onions.insert(onion_key) {
|
||||
tracing::debug!(did = %node.did, onion = %node.onion, "skipping duplicate federation node (onion already seeded)");
|
||||
continue;
|
||||
}
|
||||
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +253,10 @@ pub struct MeshConfig {
|
||||
/// Announce new Bitcoin block headers over mesh (internet-connected nodes only).
|
||||
#[serde(default)]
|
||||
pub announce_block_headers: bool,
|
||||
/// Accept Bitcoin block headers received over mesh from peers. On by default;
|
||||
/// turn off to ignore inbound headers (the receive half of issue #28).
|
||||
#[serde(default = "default_true")]
|
||||
pub receive_block_headers: bool,
|
||||
/// Steganographic encoding mode for mesh messages (Normal = disabled).
|
||||
#[serde(default)]
|
||||
pub steganography_mode: steganography::SteganographyMode,
|
||||
@@ -133,6 +264,30 @@ pub struct MeshConfig {
|
||||
/// Set to false to disable encryption for debugging or rollback.
|
||||
#[serde(default = "default_true")]
|
||||
pub encrypt_relay_messages: bool,
|
||||
/// Answer AI queries (AssistQuery) from peers using this node's local LLM
|
||||
/// (issue #50). Off by default — the node only becomes a mesh AI on opt-in.
|
||||
#[serde(default)]
|
||||
pub assistant_enabled: bool,
|
||||
/// Ollama model used to answer AI queries. None → the built-in default.
|
||||
#[serde(default)]
|
||||
pub assistant_model: Option<String>,
|
||||
/// When true (default), only federation-Trusted peers may ask; when false,
|
||||
/// any peer on the mesh may ask (spends this node's compute + airtime).
|
||||
#[serde(default = "default_true")]
|
||||
pub assistant_trusted_only: bool,
|
||||
/// Which AI backend answers queries: "claude" (the shared Claude proxy
|
||||
/// token at secrets/claude-api-key — default for now, works without a
|
||||
/// 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 {
|
||||
"claude".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -149,8 +304,14 @@ impl Default for MeshConfig {
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
announce_block_headers: false,
|
||||
receive_block_headers: true,
|
||||
steganography_mode: steganography::SteganographyMode::Normal,
|
||||
encrypt_relay_messages: true,
|
||||
assistant_enabled: false,
|
||||
assistant_model: None,
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +361,66 @@ pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load persisted mesh contact customisations (alias / notes / pinned / blocked),
|
||||
/// decrypting at rest with the node key and migrating any legacy plaintext file.
|
||||
/// Returns an empty map on any error so a read failure never loses live state.
|
||||
pub async fn load_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
) -> std::collections::HashMap<String, listener::ContactEntry> {
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let Ok(raw) = fs::read(&path).await else {
|
||||
return std::collections::HashMap::new();
|
||||
};
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
raw
|
||||
} else {
|
||||
match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh contacts: decrypt failed ({e}); keeping in-memory state");
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
},
|
||||
Err(_) => return std::collections::HashMap::new(),
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist mesh contact customisations, encrypted at rest with the node key and
|
||||
/// written atomically (temp + rename) so a crash mid-write can't corrupt them.
|
||||
pub async fn save_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
contacts: &std::collections::HashMap<String, listener::ContactEntry>,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(data_dir).await.ok();
|
||||
let content = serde_json::to_vec(contacts).context("Failed to serialize mesh contacts")?;
|
||||
let bytes = match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
Err(_) => content, // no key yet (pre-onboarding) → plaintext rather than no-write
|
||||
};
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, &bytes)
|
||||
.await
|
||||
.context("Failed to write mesh contacts tmp")?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.context("Failed to rename mesh contacts")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect serial devices that could be mesh radios.
|
||||
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
||||
pub async fn detect_devices() -> Vec<String> {
|
||||
@@ -219,6 +440,7 @@ pub struct MeshService {
|
||||
deadman_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
block_announcer_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
presence_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
scheduler_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
|
||||
// Crypto identity for this node
|
||||
our_did: String,
|
||||
@@ -232,6 +454,8 @@ pub struct MeshService {
|
||||
pub block_header_cache: Arc<BlockHeaderCache>,
|
||||
pub relay_tracker: Arc<RelayTracker>,
|
||||
pub dead_man_switch: Arc<DeadManSwitch>,
|
||||
/// Scheduled / queued outbound mesh messages (issue #50, phase 1.7).
|
||||
pub scheduler: Arc<scheduler::MeshScheduler>,
|
||||
}
|
||||
|
||||
impl MeshService {
|
||||
@@ -257,8 +481,17 @@ impl MeshService {
|
||||
Some(Arc::clone(&relay_tracker)),
|
||||
config.steganography_mode,
|
||||
config.encrypt_relay_messages,
|
||||
config.receive_block_headers,
|
||||
Arc::clone(&session_manager),
|
||||
ed_pubkey_hex.to_string(),
|
||||
listener::AssistantConfig {
|
||||
enabled: config.assistant_enabled,
|
||||
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(),
|
||||
);
|
||||
|
||||
// Derive X25519 keys from Ed25519 identity
|
||||
@@ -294,6 +527,18 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore persisted contact customisations (alias/notes/pinned/blocked),
|
||||
// decrypted with the node key, so they survive restarts.
|
||||
{
|
||||
let saved = load_mesh_contacts(data_dir).await;
|
||||
if !saved.is_empty() {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
for (pk, entry) in saved {
|
||||
contacts.insert(pk, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
@@ -303,6 +548,7 @@ impl MeshService {
|
||||
deadman_handle: None,
|
||||
block_announcer_handle: None,
|
||||
presence_handle: None,
|
||||
scheduler_handle: None,
|
||||
cmd_rx: Some(cmd_rx),
|
||||
our_did: did.to_string(),
|
||||
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
|
||||
@@ -313,6 +559,7 @@ impl MeshService {
|
||||
block_header_cache,
|
||||
relay_tracker,
|
||||
dead_man_switch,
|
||||
scheduler: Arc::new(scheduler::MeshScheduler::load(data_dir).await),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,6 +637,20 @@ impl MeshService {
|
||||
});
|
||||
self.deadman_handle = Some(dms_handle);
|
||||
|
||||
// Scheduled-message task (issue #50, phase 1.7): fires queued messages
|
||||
// when due, retrying peer DMs until the peer is back in range.
|
||||
let sched = Arc::clone(&self.scheduler);
|
||||
let sched_state = Arc::clone(&self.state);
|
||||
let sched_shutdown = self
|
||||
.shutdown_tx
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Shutdown channel not initialized"))?
|
||||
.subscribe();
|
||||
let sched_handle = tokio::spawn(async move {
|
||||
scheduler::run_scheduler(sched, sched_state, sched_shutdown).await;
|
||||
});
|
||||
self.scheduler_handle = Some(sched_handle);
|
||||
|
||||
// Spawn block header announcer (internet-connected nodes only)
|
||||
if self.config.announce_block_headers {
|
||||
let bha_state = Arc::clone(&self.state);
|
||||
@@ -406,6 +667,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()
|
||||
@@ -423,6 +685,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 {
|
||||
@@ -468,30 +742,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),
|
||||
}
|
||||
@@ -540,6 +799,10 @@ impl MeshService {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.scheduler_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.block_announcer_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
@@ -741,16 +1004,58 @@ 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 {
|
||||
let (peer_pubkey, peer_did) = {
|
||||
// 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;
|
||||
match peers.get(&contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
None if is_federation_synthetic => {
|
||||
anyhow::bail!("Unknown federation peer {}", contact_id);
|
||||
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
|
||||
// node where the mesh device table is empty. Without this fallback
|
||||
// chatting a federation contact bails "Unknown federation peer"
|
||||
// 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| {
|
||||
// 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,
|
||||
None if is_federation_synthetic => {
|
||||
let nodes = crate::federation::load_nodes(&self.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
match nodes
|
||||
.iter()
|
||||
.find(|n| federation_peer_contact_id(&n.pubkey) == contact_id)
|
||||
{
|
||||
Some(n) => (Some(n.pubkey.clone()), Some(n.did.clone())),
|
||||
None => anyhow::bail!("Unknown federation peer {}", contact_id),
|
||||
}
|
||||
None => (None, None),
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let nodes = crate::federation::load_nodes(&self.data_dir)
|
||||
.await
|
||||
@@ -868,7 +1173,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");
|
||||
@@ -1073,13 +1385,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
|
||||
@@ -1193,6 +1549,62 @@ impl MeshService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current mesh-AI assistant settings (issue #50).
|
||||
pub async fn assistant_config(&self) -> listener::AssistantConfig {
|
||||
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.
|
||||
pub async fn configure_assistant(
|
||||
&self,
|
||||
enabled: Option<bool>,
|
||||
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;
|
||||
if let Some(e) = enabled {
|
||||
a.enabled = e;
|
||||
}
|
||||
if let Some(m) = model {
|
||||
a.model = m;
|
||||
}
|
||||
if let Some(t) = trusted_only {
|
||||
a.trusted_only = t;
|
||||
}
|
||||
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
|
||||
// source of truth and is re-seeded from disk on the next start).
|
||||
let mut cfg = load_config(&self.data_dir).await.unwrap_or_default();
|
||||
{
|
||||
let a = self.state.assistant.read().await;
|
||||
cfg.assistant_enabled = a.enabled;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Update mesh configuration.
|
||||
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Scheduled / queued mesh messages (issue #50, phase 1.7).
|
||||
//!
|
||||
//! A small persisted queue of messages to send at a future time. A background
|
||||
//! task fires due messages via the listener. A message addressed to a peer that
|
||||
//! isn't currently in the contact table stays queued and retries on later ticks
|
||||
//! — i.e. it sends itself when the peer comes back in range.
|
||||
|
||||
use super::listener::{MeshCommand, MeshState};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use tracing::warn;
|
||||
|
||||
const SCHEDULER_FILE: &str = "mesh-scheduled.json";
|
||||
/// Wake interval for firing due messages.
|
||||
const TICK_SECS: u64 = 10;
|
||||
/// Drop a still-undeliverable message after this many attempts (~1h at 10s).
|
||||
const MAX_ATTEMPTS: u32 = 360;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScheduledMessage {
|
||||
pub id: u64,
|
||||
/// Direct-message target (peer contact_id), or None for a channel broadcast.
|
||||
#[serde(default)]
|
||||
pub contact_id: Option<u32>,
|
||||
/// Channel to broadcast on, or None for a direct message.
|
||||
#[serde(default)]
|
||||
pub channel: Option<u8>,
|
||||
pub body: String,
|
||||
/// Unix seconds when the message becomes due.
|
||||
pub fire_at: i64,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
pub struct MeshScheduler {
|
||||
path: PathBuf,
|
||||
queue: RwLock<Vec<ScheduledMessage>>,
|
||||
next_id: RwLock<u64>,
|
||||
}
|
||||
|
||||
impl MeshScheduler {
|
||||
pub async fn load(data_dir: &Path) -> Self {
|
||||
let path = data_dir.join(SCHEDULER_FILE);
|
||||
let queue: Vec<ScheduledMessage> = match fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let next = queue.iter().map(|m| m.id).max().unwrap_or(0) + 1;
|
||||
Self {
|
||||
path,
|
||||
queue: RwLock::new(queue),
|
||||
next_id: RwLock::new(next),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self) -> Result<()> {
|
||||
let json = {
|
||||
let q = self.queue.read().await;
|
||||
serde_json::to_string_pretty(&*q).context("serialize scheduled queue")?
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
fs::write(&self.path, json)
|
||||
.await
|
||||
.context("write scheduled queue")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
&self,
|
||||
contact_id: Option<u32>,
|
||||
channel: Option<u8>,
|
||||
body: String,
|
||||
fire_at: i64,
|
||||
) -> Result<ScheduledMessage> {
|
||||
let id = {
|
||||
let mut n = self.next_id.write().await;
|
||||
let id = *n;
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
let msg = ScheduledMessage {
|
||||
id,
|
||||
contact_id,
|
||||
channel,
|
||||
body,
|
||||
fire_at,
|
||||
attempts: 0,
|
||||
};
|
||||
self.queue.write().await.push(msg.clone());
|
||||
self.save().await?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<ScheduledMessage> {
|
||||
let mut v = self.queue.read().await.clone();
|
||||
v.sort_by_key(|m| m.fire_at);
|
||||
v
|
||||
}
|
||||
|
||||
pub async fn cancel(&self, id: u64) -> Result<bool> {
|
||||
let removed = {
|
||||
let mut q = self.queue.write().await;
|
||||
let before = q.len();
|
||||
q.retain(|m| m.id != id);
|
||||
q.len() != before
|
||||
};
|
||||
if removed {
|
||||
self.save().await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background loop: every `TICK_SECS`, fire any due messages.
|
||||
pub async fn run_scheduler(
|
||||
scheduler: Arc<MeshScheduler>,
|
||||
state: Arc<MeshState>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => fire_due(&scheduler, &state).await,
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let due: Vec<ScheduledMessage> = scheduler
|
||||
.queue
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.fire_at <= now)
|
||||
.cloned()
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut delivered: Vec<u64> = Vec::new();
|
||||
let mut failed: Vec<u64> = Vec::new();
|
||||
for msg in &due {
|
||||
if try_send(state, msg).await {
|
||||
delivered.push(msg.id);
|
||||
} else {
|
||||
failed.push(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut to_remove = delivered;
|
||||
{
|
||||
let mut q = scheduler.queue.write().await;
|
||||
for m in q.iter_mut() {
|
||||
if failed.contains(&m.id) {
|
||||
m.attempts += 1;
|
||||
if m.attempts >= MAX_ATTEMPTS {
|
||||
warn!(
|
||||
id = m.id,
|
||||
attempts = m.attempts,
|
||||
"Dropping undeliverable scheduled message"
|
||||
);
|
||||
to_remove.push(m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
q.retain(|m| !to_remove.contains(&m.id));
|
||||
}
|
||||
let _ = scheduler.save().await;
|
||||
}
|
||||
|
||||
/// Hand a due message to the radio. Returns true if it was sent (or should be
|
||||
/// dropped); false to keep it queued for a later retry (peer not in range yet).
|
||||
async fn try_send(state: &Arc<MeshState>, msg: &ScheduledMessage) -> bool {
|
||||
let payload = msg.body.clone().into_bytes();
|
||||
if let Some(channel) = msg.channel {
|
||||
return state
|
||||
.send_cmd(MeshCommand::BroadcastChannel { channel, payload })
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
if let Some(contact_id) = msg.contact_id {
|
||||
let pubkey = {
|
||||
let peers = state.peers.read().await;
|
||||
peers.get(&contact_id).and_then(|p| p.pubkey_hex.clone())
|
||||
};
|
||||
if let Some(pk) = pubkey {
|
||||
if let Ok(bytes) = hex::decode(&pk) {
|
||||
if bytes.len() >= 6 {
|
||||
let mut dest = [0u8; 6];
|
||||
dest.copy_from_slice(&bytes[..6]);
|
||||
return state
|
||||
.send_cmd(MeshCommand::SendText {
|
||||
dest_pubkey_prefix: dest,
|
||||
payload,
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peer unknown / not in range yet — keep queued, retry next tick.
|
||||
return false;
|
||||
}
|
||||
warn!("Scheduled message has neither channel nor contact_id — dropping");
|
||||
true
|
||||
}
|
||||
@@ -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.
|
||||
@@ -161,4 +193,72 @@ pub enum MeshEvent {
|
||||
payment_hash: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// An AI query arrived from a peer and was accepted for answering (#50).
|
||||
AssistQueryReceived {
|
||||
from_contact_id: u32,
|
||||
prompt: String,
|
||||
},
|
||||
/// A local-AI answer finished sending back to the asker (or failed) (#50).
|
||||
AssistResponseReady {
|
||||
req_id: u64,
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct IncomingMessage {
|
||||
/// Sender's node name (for display in group chat).
|
||||
#[serde(default)]
|
||||
pub from_name: Option<String>,
|
||||
/// Sender-assigned unique id for the message. Used to dedup reliably even
|
||||
/// when a slow-Tor retry/redelivery arrives outside the time window (#31).
|
||||
#[serde(default)]
|
||||
pub msg_id: Option<String>,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
/// "sent" or "received"
|
||||
@@ -43,18 +47,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
|
||||
PATH.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// At-rest encryption key for messages.json, derived from the node identity in
|
||||
/// `init()`. `None` only if the node key is unreadable (pre-onboarding) — in
|
||||
/// which case we persist plaintext rather than lose messages.
|
||||
fn enc_key() -> &'static Mutex<Option<[u8; 32]>> {
|
||||
static KEY: OnceLock<Mutex<Option<[u8; 32]>>> = OnceLock::new();
|
||||
KEY.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Initialize message store — load from disk. Call once at startup.
|
||||
pub async fn init(data_dir: &Path) {
|
||||
let path = data_dir.join("messages.json");
|
||||
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
|
||||
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
if let Ok(loaded) = serde_json::from_str::<MessageStore>(&content) {
|
||||
// Derive + cache the at-rest encryption key (bound to this node's identity).
|
||||
match crate::storage_crypto::derive_key(data_dir, crate::storage_crypto::DOMAIN_MESSAGES).await
|
||||
{
|
||||
Ok(k) => *enc_key().lock().unwrap_or_else(|e| e.into_inner()) = Some(k),
|
||||
Err(e) => tracing::warn!(
|
||||
"message store: encryption key unavailable ({e}); will persist plaintext"
|
||||
),
|
||||
}
|
||||
|
||||
let Ok(raw) = tokio::fs::read(&path).await else {
|
||||
return; // no file yet (new node)
|
||||
};
|
||||
// Decrypt the on-disk blob, transparently migrating a legacy plaintext file.
|
||||
let mut was_plaintext = false;
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
was_plaintext = true;
|
||||
Some(raw)
|
||||
} else {
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
match key {
|
||||
Some(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"message store: decrypt failed ({e}); NOT overwriting on-disk data"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(loaded) = serde_json::from_slice::<MessageStore>(&bytes) {
|
||||
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = loaded;
|
||||
tracing::info!("Loaded {} messages from disk", guard.messages.len());
|
||||
}
|
||||
}
|
||||
// Eagerly re-write a legacy plaintext file as encrypted on first boot.
|
||||
if was_plaintext
|
||||
&& enc_key()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
persist();
|
||||
tracing::info!("message store: migrated plaintext messages.json to encrypted at rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist current messages to disk.
|
||||
@@ -63,31 +117,62 @@ pub async fn init(data_dir: &Path) {
|
||||
fn persist() {
|
||||
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path_guard = data_path().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(ref path) = *path_guard {
|
||||
if let Ok(content) = serde_json::to_string(&*guard) {
|
||||
if let Ok(content) = serde_json::to_vec(&*guard) {
|
||||
let path = path.clone();
|
||||
drop(path_guard);
|
||||
drop(guard);
|
||||
tokio::task::spawn(async move {
|
||||
let _ = tokio::fs::write(&path, content).await;
|
||||
// Encrypt at rest when the node key is available; fall back to
|
||||
// plaintext rather than drop the write if it somehow isn't.
|
||||
let bytes = match key {
|
||||
Some(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
None => content,
|
||||
};
|
||||
// Atomic write: stage to a temp file then rename, so a crash or
|
||||
// reboot mid-write can never truncate/corrupt the real history
|
||||
// (rename is atomic on the same filesystem).
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if tokio::fs::write(&tmp, &bytes).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a received message (called from HTTP handler).
|
||||
pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) {
|
||||
pub fn store_received_sync(
|
||||
from_pubkey: &str,
|
||||
message: &str,
|
||||
from_name: Option<&str>,
|
||||
msg_id: Option<&str>,
|
||||
) {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Deduplication: skip if same pubkey + message within last 30 seconds
|
||||
let dominated = guard.messages.iter().rev().take(20).any(|m| {
|
||||
m.from_pubkey == from_pubkey
|
||||
&& m.message == message
|
||||
&& m.direction == "received"
|
||||
&& within_seconds(&m.timestamp, &ts, 30)
|
||||
});
|
||||
if dominated {
|
||||
// Deduplication. When the sender supplied a unique id, dedup on
|
||||
// (from_pubkey, msg_id) across all retained history — this is robust even
|
||||
// when a slow-Tor redelivery arrives well outside any time window (#31).
|
||||
// Older senders send no id; fall back to the legacy same-pubkey+message
|
||||
// within-30s heuristic.
|
||||
let duplicate = if let Some(id) = msg_id {
|
||||
guard
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| m.from_pubkey == from_pubkey && m.msg_id.as_deref() == Some(id))
|
||||
} else {
|
||||
guard.messages.iter().rev().take(20).any(|m| {
|
||||
m.from_pubkey == from_pubkey
|
||||
&& m.message == message
|
||||
&& m.direction == "received"
|
||||
&& within_seconds(&m.timestamp, &ts, 30)
|
||||
})
|
||||
};
|
||||
if duplicate {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +180,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
|
||||
from_pubkey: from_pubkey.to_string(),
|
||||
from_onion: None,
|
||||
from_name: from_name.map(|s| s.to_string()),
|
||||
msg_id: msg_id.map(|s| s.to_string()),
|
||||
message: message.to_string(),
|
||||
timestamp: ts,
|
||||
direction: "received".to_string(),
|
||||
@@ -104,8 +190,13 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
|
||||
persist();
|
||||
}
|
||||
|
||||
pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) {
|
||||
store_received_sync(from_pubkey, message, from_name);
|
||||
pub async fn store_received(
|
||||
from_pubkey: &str,
|
||||
message: &str,
|
||||
from_name: Option<&str>,
|
||||
msg_id: Option<&str>,
|
||||
) {
|
||||
store_received_sync(from_pubkey, message, from_name, msg_id);
|
||||
}
|
||||
|
||||
/// Store a sent message (for display in Archipelago channel).
|
||||
@@ -115,6 +206,7 @@ pub fn store_sent(message: &str) {
|
||||
from_pubkey: "me".to_string(),
|
||||
from_onion: None,
|
||||
from_name: None,
|
||||
msg_id: None,
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
direction: "sent".to_string(),
|
||||
@@ -270,6 +362,9 @@ pub async fn send_to_peer(
|
||||
"message": payload_message,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"encrypted": encrypted,
|
||||
// Unique per-message id so receivers can dedup reliably even across
|
||||
// slow-Tor retries/redeliveries (#31). Old receivers ignore it.
|
||||
"msg_id": uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
if let Some(name) = from_name {
|
||||
body["from_name"] = serde_json::Value::String(name.to_string());
|
||||
|
||||
@@ -27,7 +27,7 @@ const D_TAG: &str = "archipelago-node";
|
||||
const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"];
|
||||
|
||||
/// Load or create Nostr keys (secp256k1) for node discovery.
|
||||
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
|
||||
pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
|
||||
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
|
||||
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
|
||||
|
||||
@@ -78,7 +78,7 @@ async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>>
|
||||
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
|
||||
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
|
||||
/// Requires tor_proxy to avoid leaking IP to relay operators.
|
||||
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
||||
pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
||||
let client = if let Some(proxy_str) = tor_proxy {
|
||||
let addr = parse_proxy_addr(proxy_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
|
||||
|
||||
@@ -18,6 +18,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
4080, 8999, 50001, // Mempool stack
|
||||
23000, // BTCPay
|
||||
8173, 8174, 8175, // Fedimint
|
||||
8178, // Fedimint client daemon (fedimint-clientd REST)
|
||||
8123, // Home Assistant
|
||||
3000, // Grafana
|
||||
11434, // Ollama
|
||||
@@ -28,6 +29,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, // FIPS TCP fallback
|
||||
8336, // FIPS UI (fips-ui)
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
||||
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
|
||||
//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key
|
||||
//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key
|
||||
//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor)
|
||||
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
|
||||
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
|
||||
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
|
||||
@@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
||||
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
|
||||
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
|
||||
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
|
||||
|
||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
/// Derive the fleet **release-root** Ed25519 signing key.
|
||||
///
|
||||
/// This is a *publisher-side* derivation: only the holder of the release master
|
||||
/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this —
|
||||
/// they pin the corresponding PUBLIC key as a trust anchor (see
|
||||
/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs.
|
||||
///
|
||||
/// Keeping it seed-derived means the signing key is reproducible from a
|
||||
/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it
|
||||
/// is domain-separated from every node/identity key by its HKDF info string.
|
||||
pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
||||
let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?;
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
/// Derive an identity's Ed25519 signing key by index.
|
||||
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
|
||||
let info = format!("archipelago/identity/{}/ed25519/v1", index);
|
||||
@@ -543,4 +561,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_key_known_answer_vs_python_verifier() {
|
||||
// Cross-checks scripts/verify-seed-derivation.py: same mnemonic must
|
||||
// produce the same node_key bytes in Rust and in the Python verifier.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key = derive_node_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(key.to_bytes()),
|
||||
"3b4f4a1450450260ae360adb9c33ea5eb86356fa14454ca0067dd4b51ea8be87"
|
||||
);
|
||||
let nostr = derive_node_nostr_key(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(nostr.secret_key().to_secret_bytes()),
|
||||
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_root_deterministic_and_domain_separated() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let a = derive_release_root_ed25519(&seed).unwrap();
|
||||
let b = derive_release_root_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
a.verifying_key().as_bytes(),
|
||||
b.verifying_key().as_bytes(),
|
||||
"Same mnemonic must produce the same release-root key"
|
||||
);
|
||||
// Must NOT collide with the node key — different HKDF domain.
|
||||
let node = derive_node_ed25519(&seed).unwrap();
|
||||
assert_ne!(
|
||||
a.verifying_key().as_bytes(),
|
||||
node.verifying_key().as_bytes(),
|
||||
"Release-root key must be domain-separated from the node key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_root_known_answer() {
|
||||
// KAT pins the derivation so the signing ceremony, the pinned anchor,
|
||||
// and any external verifier agree on the bytes for a given mnemonic.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key = derive_release_root_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(key.to_bytes()),
|
||||
"613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71",
|
||||
"release-root private key KAT"
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(key.verifying_key().to_bytes()),
|
||||
"995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2",
|
||||
"release-root public key KAT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,31 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
// DHT swarm-assist (Phase 3): build the iroh provider once at startup so
|
||||
// release downloads can fetch from peers (origin always wins) and seed
|
||||
// what they hold. Inert unless built with `iroh-swarm` AND swarm_enabled.
|
||||
if let Err(e) = crate::swarm::init(
|
||||
&config.data_dir,
|
||||
&config.nostr_relays,
|
||||
config.nostr_tor_proxy.as_deref(),
|
||||
config.swarm_enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e);
|
||||
}
|
||||
|
||||
// Resume any cross-mint ecash swap interrupted by a previous crash
|
||||
// (paid the source mint but never claimed the target tokens). Best-effort.
|
||||
match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await {
|
||||
Ok(0) => {}
|
||||
Ok(reclaimed) => tracing::info!(
|
||||
"Resumed interrupted cross-mint swaps: reclaimed {} sats",
|
||||
reclaimed
|
||||
),
|
||||
Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e),
|
||||
}
|
||||
|
||||
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
|
||||
@@ -241,12 +266,86 @@ impl Server {
|
||||
warn!("Mesh service start failed (non-fatal): {}", e);
|
||||
} else {
|
||||
info!("📡 Mesh networking started");
|
||||
|
||||
// Push mesh peer changes to open WebSockets instantly
|
||||
// instead of the UI polling every 5s (#48): subscribe to
|
||||
// mesh events and nudge the data-model revision (debounced)
|
||||
// so /ws/db clients refetch peers on discovery/update.
|
||||
let mut rx = mesh_service.state().event_tx.subscribe();
|
||||
let sm = state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::{Duration, Instant};
|
||||
let mut last: Option<Instant> = None;
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(crate::mesh::MeshEvent::PeerDiscovered(_))
|
||||
| Ok(crate::mesh::MeshEvent::PeerUpdated(_)) => {
|
||||
// Debounce advert storms to ~2 Hz.
|
||||
if last
|
||||
.map(|t| t.elapsed() < Duration::from_millis(500))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last = Some(Instant::now());
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
_,
|
||||
)) => continue,
|
||||
Err(_) => break, // sender dropped → mesh stopped
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
api_handler
|
||||
.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) => {
|
||||
@@ -329,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?;
|
||||
@@ -508,6 +700,7 @@ impl Server {
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
let state = state_manager.clone();
|
||||
let rpc = api_handler.rpc_handler().clone();
|
||||
tokio::spawn(async move {
|
||||
// First run 60s after boot to let onboarding settle.
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
@@ -558,6 +751,10 @@ impl Server {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
// After syncing every peer, push the names/roster just
|
||||
// learned (into nodes.json) into the live mesh peer table
|
||||
// so chat contacts refresh without a restart (#42).
|
||||
rpc.refresh_federation_mesh_peers().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -769,6 +966,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
| "/archipelago/mesh-typed"
|
||||
| "/dwn"
|
||||
| "/transport/inbox"
|
||||
// Content *catalog* — the peer-browse entry point. This is the
|
||||
// exact path `/content` (no trailing slash); the prefix match
|
||||
// below only covers `/content/<id>` item fetches, so without
|
||||
// this the catalog 404s over the mesh and `content.browse-peer`
|
||||
// fails with "Peer returned error: 404 Not Found" (and never
|
||||
// falls back to Tor, since a 404 is a successful HTTP exchange).
|
||||
| "/content"
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
@@ -1248,6 +1452,7 @@ fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port:
|
||||
fn fallback_package_port(app_id: &str) -> Option<u16> {
|
||||
match app_id {
|
||||
"fedimint" | "fedimintd" => Some(8175),
|
||||
"fedimint-clientd" => Some(8178),
|
||||
"filebrowser" => Some(8083),
|
||||
"indeedhub" => Some(7778),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
@@ -1378,6 +1583,25 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
// slash). It must be reachable over the peer (FIPS) listener, else
|
||||
// `content.browse-peer` 404s over the mesh. Item fetches are
|
||||
// "/content/<id>".
|
||||
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
|
||||
assert!(
|
||||
is_peer_allowed_path("/content/abc123"),
|
||||
"items must be allowed"
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_transitional_state_on_merge() {
|
||||
// existing: user initiated a stop, spawn_transitional set Stopping.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! At-rest encryption for local state stores (chat messages, mesh contacts).
|
||||
//!
|
||||
//! Best-practice envelope, matching `credentials::store`:
|
||||
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
|
||||
//! seed-derived and never leaves the device, so each store is bound to this
|
||||
//! node's identity — a stolen disk image is unreadable without it, and the
|
||||
//! per-domain separator means one store's key can't open another.
|
||||
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
|
||||
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
|
||||
//! tamper-evident — any on-disk modification fails to open.
|
||||
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
|
||||
//! then re-written encrypted on the next save. No data is stranded.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
/// Domain separators — one per store so keys never overlap.
|
||||
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
|
||||
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
|
||||
|
||||
/// Derive a 32-byte key bound to this node's identity for a given store domain.
|
||||
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.context("reading node key for at-rest encryption")?;
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(domain);
|
||||
hasher.update(&key_bytes);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hasher.finalize());
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
|
||||
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
let ct = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
plaintext,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt `nonce ‖ ciphertext`.
|
||||
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("ciphertext too short");
|
||||
}
|
||||
let (nonce, ct) = data.split_at(12);
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ct,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
|
||||
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
|
||||
/// reliable migration signal.
|
||||
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
||||
matches!(raw.first(), Some(b'{') | Some(b'['))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trips() {
|
||||
let key = [7u8; 32];
|
||||
let msg = br#"{"messages":[{"m":"hi"}]}"#;
|
||||
let sealed = seal(msg, &key).unwrap();
|
||||
// Encrypted output must NOT be readable plaintext.
|
||||
assert!(!is_plaintext_json(&sealed));
|
||||
assert_ne!(&sealed[12..], &msg[..]);
|
||||
assert_eq!(open(&sealed, &key).unwrap(), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_on_wrong_key_or_tamper() {
|
||||
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
|
||||
assert!(open(&sealed, &[2u8; 32]).is_err());
|
||||
let mut tampered = sealed.clone();
|
||||
*tampered.last_mut().unwrap() ^= 0x01;
|
||||
assert!(open(&tampered, &[1u8; 32]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_plaintext_vs_ciphertext() {
|
||||
assert!(is_plaintext_json(b"{\"a\":1}"));
|
||||
assert!(is_plaintext_json(b"[]"));
|
||||
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user